Merge branch 'main' into fix/user-auth-error-handling
Resolved conflicts in: - api/accounts/urls.py: Took main's complete URL set (verify/refresh/logout/me) - web/src/App.tsx: Took main's route structure with PublicRoute/ProtectedRoute wrappers - web/src/services/authApi.ts: Combined main's robust interceptor with PR's extractErrorMessage + getAccessToken/getRefreshToken helpers - web/src/contexts/AuthContext.tsx: Combined main's full auth flow (refreshAccessToken, getProfile, logoutUser) with PR's extractErrorMessage and user_data persistence
This commit is contained in:
+80
-12
@@ -1,26 +1,94 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import AppLayout from "./components/AppLayout";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import RegisterPage from "./pages/RegisterPage";
|
||||
|
||||
function ProtectedRoute({ children }: { children: ReactNode }): ReactNode {
|
||||
const { state } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (state.isInitializing) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: ReactNode }): ReactNode {
|
||||
const { state } = useAuth();
|
||||
|
||||
if (state.isInitializing) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.isAuthenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export default function App(): ReactNode {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
|
||||
{/* Protected routes (require authentication) */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<RegisterPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HomePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Container from "@mui/material/Container";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
@@ -20,15 +22,28 @@ const theme = createTheme({
|
||||
});
|
||||
|
||||
export default function AppLayout(): ReactNode {
|
||||
const { state, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Box sx={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>
|
||||
<AppBar position="sticky">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
|
||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 700, flexGrow: 1 }}>
|
||||
Job Tracker
|
||||
</Typography>
|
||||
{state.isAuthenticated && (
|
||||
<Button color="inherit" onClick={handleLogout}>
|
||||
Sign Out
|
||||
</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container component="main" maxWidth="lg" sx={{ mt: 4, mb: 4, flexGrow: 1 }}>
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
import {
|
||||
loginUser,
|
||||
registerUser,
|
||||
extractErrorMessage,
|
||||
getProfile,
|
||||
logoutUser,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
refreshAccessToken,
|
||||
extractErrorMessage,
|
||||
getAccessToken,
|
||||
type UserProfile,
|
||||
type LoginPayload,
|
||||
@@ -24,7 +27,7 @@ interface AuthState {
|
||||
user: UserProfile | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isRestoring: boolean;
|
||||
isInitializing: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -32,7 +35,7 @@ const initialState: AuthState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isRestoring: true, // starts true until we check localStorage
|
||||
isInitializing: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
@@ -42,8 +45,8 @@ type AuthAction =
|
||||
| { type: "AUTH_SUCCESS"; payload: UserProfile }
|
||||
| { type: "AUTH_FAILURE"; payload: string }
|
||||
| { type: "LOGOUT" }
|
||||
| { type: "CLEAR_ERROR" }
|
||||
| { type: "RESTORE_COMPLETE"; payload: UserProfile | null };
|
||||
| { type: "INIT_COMPLETE" }
|
||||
| { type: "CLEAR_ERROR" };
|
||||
|
||||
function authReducer(state: AuthState, action: AuthAction): AuthState {
|
||||
switch (action.type) {
|
||||
@@ -53,7 +56,7 @@ function authReducer(state: AuthState, action: AuthAction): AuthState {
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
isRestoring: false,
|
||||
isInitializing: false,
|
||||
isAuthenticated: true,
|
||||
user: action.payload,
|
||||
error: null,
|
||||
@@ -62,20 +65,15 @@ function authReducer(state: AuthState, action: AuthAction): AuthState {
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
isRestoring: false,
|
||||
isInitializing: false,
|
||||
error: action.payload,
|
||||
};
|
||||
case "LOGOUT":
|
||||
return { ...initialState, isRestoring: false };
|
||||
return { ...initialState, isInitializing: false };
|
||||
case "INIT_COMPLETE":
|
||||
return { ...state, isInitializing: false };
|
||||
case "CLEAR_ERROR":
|
||||
return { ...state, error: null };
|
||||
case "RESTORE_COMPLETE":
|
||||
return {
|
||||
...state,
|
||||
isRestoring: false,
|
||||
isAuthenticated: action.payload !== null,
|
||||
user: action.payload,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -87,7 +85,7 @@ interface AuthContextValue {
|
||||
dispatch: Dispatch<AuthAction>;
|
||||
login: (payload: LoginPayload) => Promise<void>;
|
||||
register: (payload: RegisterPayload) => Promise<void>;
|
||||
logout: () => void;
|
||||
logout: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
@@ -97,28 +95,48 @@ const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
|
||||
// Restore auth state from localStorage on mount
|
||||
// On mount: try to refresh the access token and fetch user profile
|
||||
useEffect(() => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
// We have a stored token — try to validate it by fetching the user profile.
|
||||
// For now, assume the token is valid if it exists. A full implementation
|
||||
// would call a /api/auth/me/ endpoint to verify the token.
|
||||
// Since we don't have that endpoint, we'll set isRestoring=false and let
|
||||
// the user see the authenticated UI. API calls will fail at runtime if the
|
||||
// token is expired (and the refresh interceptor handles that).
|
||||
const userData = localStorage.getItem("user_data");
|
||||
if (userData) {
|
||||
try {
|
||||
const user: UserProfile = JSON.parse(userData);
|
||||
dispatch({ type: "AUTH_SUCCESS", payload: user });
|
||||
return;
|
||||
} catch {
|
||||
// corrupt stored data — proceed with unauthenticated state
|
||||
const initAuth = async () => {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (!refreshToken) {
|
||||
// Still check for legacy access_token to restore from localStorage
|
||||
const accessToken = getAccessToken();
|
||||
if (accessToken) {
|
||||
const userData = localStorage.getItem("user_data");
|
||||
if (userData) {
|
||||
try {
|
||||
const user: UserProfile = JSON.parse(userData);
|
||||
dispatch({ type: "AUTH_SUCCESS", payload: user });
|
||||
return;
|
||||
} catch {
|
||||
// corrupt stored data — proceed with unauthenticated state
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatch({ type: "INIT_COMPLETE" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
dispatch({ type: "RESTORE_COMPLETE", payload: null });
|
||||
|
||||
try {
|
||||
// Try refreshing the access token first
|
||||
const tokens = await refreshAccessToken(refreshToken);
|
||||
setTokens(tokens.access, tokens.refresh);
|
||||
|
||||
// Fetch user profile with the fresh token
|
||||
const profile = await getProfile();
|
||||
// Persist user data for restoration if refresh token expires
|
||||
localStorage.setItem("user_data", JSON.stringify(profile));
|
||||
dispatch({ type: "AUTH_SUCCESS", payload: profile });
|
||||
} catch {
|
||||
// Token invalid or expired — clear everything
|
||||
clearTokens();
|
||||
localStorage.removeItem("user_data");
|
||||
dispatch({ type: "LOGOUT" });
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (payload: LoginPayload) => {
|
||||
@@ -140,7 +158,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
await registerUser(payload);
|
||||
// Registration succeeded — the page component handles redirect to /login.
|
||||
// Set isLoading=false by clearing auth state (no auto-authentication).
|
||||
dispatch({ type: "LOGOUT" });
|
||||
} catch (err: unknown) {
|
||||
const message = extractErrorMessage(err);
|
||||
@@ -149,7 +166,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
const logout = useCallback(async () => {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await logoutUser(refreshToken);
|
||||
} catch {
|
||||
// Even if the server request fails, clear local state
|
||||
}
|
||||
}
|
||||
clearTokens();
|
||||
localStorage.removeItem("user_data");
|
||||
dispatch({ type: "LOGOUT" });
|
||||
|
||||
@@ -11,9 +11,20 @@ interface UseDashboardDataResult {
|
||||
const API_BASE = "/api";
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
const token = localStorage.getItem("access_token");
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const response = await fetch(url, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
(body as { error?: string }).error ||
|
||||
`HTTP ${response.status}: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
+83
-37
@@ -1,10 +1,5 @@
|
||||
import axios, { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
interface QueuedRequest {
|
||||
resolve: (token: string) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}
|
||||
|
||||
interface RetryConfig extends InternalAxiosRequestConfig {
|
||||
_retry?: boolean;
|
||||
}
|
||||
@@ -46,34 +41,52 @@ apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
// ── Response interceptor: auto-refresh on 401 ─────────────────────────
|
||||
// ── Response interceptor: auto-refresh on 401, retry once ─────────────
|
||||
|
||||
let isRefreshing = false;
|
||||
let pendingRequests: QueuedRequest[] = [];
|
||||
let failedQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
function processQueue(error: unknown, token: string | null = null): void {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else if (token) {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as RetryConfig | undefined;
|
||||
const originalRequest = error.config as RetryConfig;
|
||||
|
||||
// Only attempt refresh if it's a 401, not already retried, and we have a refresh token
|
||||
if (!originalRequest) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Only handle 401s that aren't already refresh/login/register/logout attempts
|
||||
if (
|
||||
!originalRequest ||
|
||||
error.response?.status !== 401 ||
|
||||
originalRequest._retry ||
|
||||
!getRefreshToken()
|
||||
originalRequest.url?.includes("/api/auth/token/refresh/") ||
|
||||
originalRequest.url?.includes("/api/auth/login/") ||
|
||||
originalRequest.url?.includes("/api/auth/register/") ||
|
||||
originalRequest.url?.includes("/api/auth/logout/")
|
||||
) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// If already refreshing, queue this request
|
||||
if (isRefreshing) {
|
||||
// Queue this request until the refresh completes
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
pendingRequests.push({ resolve, reject });
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
originalRequest.headers!.Authorization = `Bearer ${token}`;
|
||||
return apiClient(originalRequest);
|
||||
});
|
||||
}
|
||||
@@ -81,34 +94,39 @@ apiClient.interceptors.response.use(
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
const refreshToken = getRefreshToken();
|
||||
|
||||
if (!refreshToken) {
|
||||
isRefreshing = false;
|
||||
clearTokens();
|
||||
window.location.href = "/login";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${apiClient.defaults.baseURL}/api/auth/token/refresh/`,
|
||||
{ refresh: getRefreshToken() },
|
||||
{ refresh: refreshToken }
|
||||
);
|
||||
const newAccess: string = response.data.access;
|
||||
localStorage.setItem("access_token", newAccess);
|
||||
|
||||
// Replay queued requests with the new token
|
||||
pendingRequests.forEach((p) => p.resolve(newAccess));
|
||||
pendingRequests = [];
|
||||
const newAccessToken = response.data.access;
|
||||
const newRefreshToken = response.data.refresh;
|
||||
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
|
||||
}
|
||||
setTokens(newAccessToken, newRefreshToken);
|
||||
|
||||
processQueue(null, newAccessToken);
|
||||
|
||||
originalRequest.headers!.Authorization = `Bearer ${newAccessToken}`;
|
||||
return apiClient(originalRequest);
|
||||
} catch {
|
||||
// Refresh failed — clear tokens and reject all queued requests
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
clearTokens();
|
||||
pendingRequests.forEach((p) =>
|
||||
p.reject(new Error("Session expired. Please sign in again.")),
|
||||
);
|
||||
pendingRequests = [];
|
||||
return Promise.reject(error);
|
||||
window.location.href = "/login";
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// ── Error extraction from Axios responses ─────────────────────────────
|
||||
@@ -158,7 +176,7 @@ export function extractErrorMessage(err: unknown): string {
|
||||
return "An unexpected error occurred. Please try again.";
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RegisterPayload {
|
||||
email: string;
|
||||
@@ -175,7 +193,6 @@ export interface LoginPayload {
|
||||
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
@@ -186,7 +203,7 @@ export interface LoginResponse {
|
||||
refresh: string;
|
||||
}
|
||||
|
||||
// ── API functions ─────────────────────────────────────────────────────
|
||||
// ── API functions ──────────────────────────────────────────────────────
|
||||
|
||||
export function registerUser(payload: RegisterPayload): Promise<UserProfile> {
|
||||
return apiClient
|
||||
@@ -200,4 +217,33 @@ export function loginUser(payload: LoginPayload): Promise<LoginResponse> {
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export { apiClient, setTokens, clearTokens, getAccessToken, getRefreshToken };
|
||||
export function getProfile(): Promise<UserProfile> {
|
||||
return apiClient
|
||||
.get<UserProfile>("/api/auth/me/")
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function logoutUser(refreshToken: string): Promise<void> {
|
||||
return apiClient
|
||||
.post("/api/auth/logout/", { refresh: refreshToken })
|
||||
.then(() => {});
|
||||
}
|
||||
|
||||
export function refreshAccessToken(
|
||||
refreshToken: string
|
||||
): Promise<{ access: string; refresh: string }> {
|
||||
return apiClient
|
||||
.post<{ access: string; refresh: string }>(
|
||||
"/api/auth/token/refresh/",
|
||||
{ refresh: refreshToken }
|
||||
)
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export {
|
||||
apiClient,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
};
|
||||
Reference in New Issue
Block a user