diff --git a/backend/config/settings.py b/backend/config/settings.py
index 1d0a026..258a607 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -105,7 +105,8 @@ REST_FRAMEWORK = {
}
SIMPLE_JWT = {
- "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
+ "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
+ # "ACCESS_TOKEN_LIFETIME": timedelta(seconds=10), # lazy testing
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3aa2c45..fb3fbdb 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,11 +1,33 @@
+import { useEffect } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
+import Logo from "./components/Logo";
import Activate from "./pages/Activate";
import Drawer from "./pages/Drawer";
import Home from "./pages/Home";
+import Login from "./pages/Login";
import Register from "./pages/Register";
import VerifyEmail from "./pages/VerifyEmail";
+import { useAuth } from "./store/useAuth";
export default function App() {
+ const { checkAuth, isInitializing } = useAuth();
+
+ useEffect(() => {
+ checkAuth();
+ }, [checkAuth]);
+
+ if (isInitializing) {
+ return (
+
+ );
+ }
+
return (
@@ -14,7 +36,8 @@ export default function App() {
} />
} />
} />
- } />
+ } />
+ } />
} />
diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts
index 51b4c41..3707016 100644
--- a/frontend/src/api/apiClient.ts
+++ b/frontend/src/api/apiClient.ts
@@ -13,15 +13,23 @@ authApiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (
- error.response.status === 401 &&
- !error.config.url?.includes("refresh/")
+ error.response?.status === 401 &&
+ !error.config?.url?.includes("refresh/")
) {
- // token expired, refresh it
try {
const response = await authApiClient.post("refresh/");
if (response.status === 200) {
- // refresh successful, retry the request
- return authApiClient(error.config);
+ const newAccessToken = response.data.access;
+
+ // update the auth store so the retry uses the new token
+ useAuth.setState({
+ accessToken: newAccessToken,
+ isAuthenticated: true,
+ });
+ if (error.config) {
+ error.config.headers.Authorization = `Bearer ${newAccessToken}`;
+ return authApiClient(error.config);
+ }
}
} catch (error) {
return Promise.reject(error);
diff --git a/frontend/src/components/ui/FormField.tsx b/frontend/src/components/ui/FormField.tsx
index 539e4a9..6d86a91 100644
--- a/frontend/src/components/ui/FormField.tsx
+++ b/frontend/src/components/ui/FormField.tsx
@@ -31,7 +31,7 @@ export default function FormField({
error ? "input-error" : ""
}`}
/>
- {error && {error}
}
+ {error && {error}
}
);
}
diff --git a/frontend/src/pages/Drawer.tsx b/frontend/src/pages/Drawer.tsx
index b89172c..123e5ae 100644
--- a/frontend/src/pages/Drawer.tsx
+++ b/frontend/src/pages/Drawer.tsx
@@ -1,15 +1,21 @@
-import Logo from "../components/Logo";
+import { useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import { useAuth } from "../store/useAuth";
+
+export default function Drawer() {
+ const { user, isAuthenticated } = useAuth();
+ const navigate = useNavigate();
+
+ // Redirect to login if not authenticated
+ useEffect(() => {
+ if (!isAuthenticated) {
+ navigate("/login");
+ }
+ }, [isAuthenticated, navigate]);
+
+ if (!user) return null;
-export default function Login() {
return (
-
-
- Login to
-
-
-
-
+
);
}
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
new file mode 100644
index 0000000..7efe40e
--- /dev/null
+++ b/frontend/src/pages/Login.tsx
@@ -0,0 +1,109 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import axios from "axios";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { useNavigate } from "react-router-dom";
+import { z } from "zod";
+import Logo from "../components/Logo";
+import FormField from "../components/ui/FormField";
+import { useAuth } from "../store/useAuth";
+
+const loginSchema = z.object({
+ email: z.email("Please enter a valid email"),
+ password: z.string().min(1, "Password is required"),
+});
+
+type LoginInputs = z.infer;
+
+export default function Login() {
+ const navigate = useNavigate();
+ const [isLoading, setIsLoading] = useState(false);
+ const [apiError, setApiError] = useState(null);
+ const login = useAuth((state) => state.login);
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(loginSchema),
+ });
+
+ const onSubmit = async (data: LoginInputs) => {
+ setIsLoading(true);
+ setApiError(null);
+ try {
+ await login(data);
+ navigate("/drawer");
+ } catch (err) {
+ console.error("Login error:", err);
+ let message = "Invalid email or password";
+ if (axios.isAxiosError(err)) {
+ message =
+ err.response?.data?.detail || err.response?.data?.message || message;
+ }
+ setApiError(message);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/store/useAuth.ts b/frontend/src/store/useAuth.ts
index 4233d41..11a938b 100644
--- a/frontend/src/store/useAuth.ts
+++ b/frontend/src/store/useAuth.ts
@@ -1,36 +1,64 @@
import { create } from "zustand";
import authApiClient from "../api/apiClient";
+interface UserProfile {
+ public_id: string;
+ email: string;
+ full_name: string;
+}
+
interface AuthState {
accessToken: string | null;
- refreshToken: string | null;
isAuthenticated: boolean;
- user: any | null;
+ user: UserProfile | null;
+ isInitializing: boolean; // refresh in transit
login: (credentials: any) => Promise;
logout: () => Promise;
+ checkAuth: () => Promise;
}
export const useAuth = create((set) => ({
accessToken: null,
- refreshToken: null,
isAuthenticated: false,
user: null,
+ isInitializing: true,
+
login: async (credentials: any) => {
const response = await authApiClient.post("login/", credentials);
set({
accessToken: response.data.access,
- refreshToken: response.data.refresh,
isAuthenticated: true,
user: response.data.user,
});
},
+
logout: async () => {
- await authApiClient.post("logout/");
- set({
- accessToken: null,
- refreshToken: null,
- isAuthenticated: false,
- user: null,
- });
+ try {
+ await authApiClient.post("logout/");
+ } finally {
+ set({
+ accessToken: null,
+ isAuthenticated: false,
+ user: null,
+ });
+ }
+ },
+
+ checkAuth: async () => {
+ try {
+ const response = await authApiClient.get("me/");
+ set({
+ user: response.data,
+ isAuthenticated: true,
+ });
+ } catch (err) {
+ console.error("Check auth error:", err);
+ set({
+ user: null,
+ isAuthenticated: false,
+ });
+ } finally {
+ set({ isInitializing: false });
+ }
},
}));