Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/env/dev.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ SQL_PORT=5432
# SQL_SSL_MODE=require

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=
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
V1_API_ENDPOINTS,
CONVERSATION_ENDPOINTS,
AUTH_ENDPOINTS,
endpoints,
} from "./endpoints";

Expand Down Expand Up @@ -31,6 +32,70 @@
(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"],
Expand Down Expand Up @@ -97,7 +162,7 @@

interface StreamCallbacks {
onContent?: (content: string) => void;
onComplete?: (data: { embeddings_info: any[]; done: boolean }) => void;

Check warning on line 165 in frontend/src/api/apiClient.ts

View workflow job for this annotation

GitHub Actions / Lint and Build

Unexpected any. Specify a different type
onError?: (error: string) => void;
onMetadata?: (data: { question: string; embeddings_count: number }) => void;
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ 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/`,
JWT_REFRESH: `${API_BASE}/auth/jwt/refresh/`,
} as const;

/**
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,14 @@ const Header: React.FC<LoginFormProps> = ({ isAuthenticated, isSuperuser }) => {
Balancer
</span>
<Chat showChat={showChat} setShowChat={setShowChat} />
{isAuthenticated && authLinks()}
{isAuthenticated ? authLinks() : (
<Link
to="/login"
className="font-satoshi flex cursor-pointer items-center text-black hover:text-blue-600"
>
Log In
</Link>
)}
</div>
<MdNavBar handleForm={handleForm} isAuthenticated={isAuthenticated} />
</header>
Expand Down
20 changes: 15 additions & 5 deletions frontend/src/components/Header/MdNavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,25 @@ const MdNavBar = (props: LoginFormProps) => {
Support Development
</a>
</li>
{isAuthenticated &&
{isAuthenticated ? (
<li className="border-b border-gray-300 p-4">
<Link
to="/logout"
className="mr-9 text-black hover:border-b-2 hover:border-blue-600 hover:text-black hover:no-underline"
>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
</Link>
</li>
) : (
<li className="border-b border-gray-300 p-4">
<Link
to="/login"
className="mr-9 text-black hover:border-b-2 hover:border-blue-600 hover:text-black hover:no-underline"
>
Log In
</Link>
</li>
}
)}
</ul>
</div>
<Chat showChat={showChat} setShowChat={setShowChat}/>
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/components/ProtectedRoute/AdminRoute.tsx
Original file line number Diff line number Diff line change
@@ -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<AppDispatch>();
const { isAuthenticated, isSuperuser } = useSelector((state: RootState) => state.auth);

useEffect(() => {
if (isAuthenticated === null) {
dispatch(checkAuthenticated());
}
}, [dispatch, isAuthenticated]);

if (isAuthenticated === null) {
return <Spinner />;
}

if (!isAuthenticated) {
return <Navigate to="/login" replace state={{ from: location }} />;
}

if (!isSuperuser) {
return <Navigate to="/" replace />;
}

return children;
};

export default AdminRoute;
76 changes: 76 additions & 0 deletions frontend/src/pages/Activate/Activate.tsx
Original file line number Diff line number Diff line change
@@ -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<AppDispatch>();
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 (
<Layout>
<Spinner />
</Layout>
);
}

if (status === "error") {
return (
<Layout>
<section className="mx-auto mt-24 w-[20rem] md:mt-48 md:w-[32rem] text-center">
<div className="mb-4 rounded-md bg-white px-3 pb-12 pt-6 shadow-md ring-1 md:px-12">
<h2 className="blue_gradient mb-4 font-satoshi text-3xl font-bold text-gray-600">
Activation failed
</h2>
<p className="text-gray-600 mb-6">
This activation link is invalid or has already been used. Please register again or request a new activation email.
</p>
<Link to="/register" className="btnBlue w-full text-lg text-center block">
Back to register
</Link>
</div>
</section>
</Layout>
);
}

return (
<Layout>
<section className="mx-auto mt-24 w-[20rem] md:mt-48 md:w-[32rem] text-center">
<div className="mb-4 rounded-md bg-white px-3 pb-12 pt-6 shadow-md ring-1 md:px-12">
<h2 className="blue_gradient mb-4 font-satoshi text-3xl font-bold text-gray-600">
Email verified
</h2>
<p className="text-gray-600 mb-6">
Your account has been activated. You can now log in.
</p>
<Link to="/login" className="btnBlue w-full text-lg text-center block">
Continue to log in
</Link>
</div>
</section>
</Layout>
);
};

export default Activate;
32 changes: 10 additions & 22 deletions frontend/src/pages/Login/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
>
<div className="flex flex-col items-center justify-center">
{/* {errorMessage && <div className="text-red-500">{errorMessage}</div>} */}
<h2 className="blue_gradient mb-6 font-satoshi text-3xl font-bold text-gray-600">
Welcome
Log in
</h2>

<blockquote className="p-4 mb-4 border-s-4 border-yellow-500 bg-amber-50 flex gap-5 items-center">
<div className="mb-2 text-yellow-500">
<FaExclamationTriangle size={24} />
</div>
<div>
<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>
</div>
</blockquote>
</div>
<ErrorMessage errors={errors} />
<div className="mb-4 mt-5">
Expand Down Expand Up @@ -113,18 +102,17 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) {
Sign In
</button>
</div>
<div className="mt-4 flex justify-between text-sm">
<Link to="/register" className="text-blue-600 hover:underline">
Don't have an account? Sign up
</Link>
<Link to="/resetPassword" className="text-blue-600 hover:underline">
Forgot password?
</Link>
</div>
</form>
</section>
{ loading && <LoadingSpinner /> }

{/* <p>
Don't have an account?{" "}
<Link to="/register" className="font-bold hover:text-blue-600">
{" "}
Register here
</Link>
.
</p> */}
{ loading && <LoadingSpinner /> }
</>
);
}
Expand Down
Loading
Loading