From a3b9c7225d842918220a505a6a082426cd2e10d5 Mon Sep 17 00:00:00 2001 From: Muhammad Sadiq Ali Date: Mon, 27 Jul 2026 20:52:06 +0500 Subject: [PATCH 1/9] Add login, signup, and backend health check pages with routing --- frontend/.env.example | 0 frontend/app/routes.ts | 9 +++- frontend/app/routes/health.tsx | 48 +++++++++++++++++ frontend/app/routes/login.tsx | 85 ++++++++++++++++++++++++++++++ frontend/app/routes/signup.tsx | 95 ++++++++++++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/app/routes/health.tsx create mode 100644 frontend/app/routes/login.tsx create mode 100644 frontend/app/routes/signup.tsx diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index 102b402..954e38b 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -1,3 +1,8 @@ -import { type RouteConfig, index } from "@react-router/dev/routes"; +import { type RouteConfig, index, route } from "@react-router/dev/routes"; -export default [index("routes/home.tsx")] satisfies RouteConfig; +export default [ + index("routes/home.tsx"), + route("login", "routes/login.tsx"), + route("signup", "routes/signup.tsx"), + route("health", "routes/health.tsx"), +] satisfies RouteConfig; diff --git a/frontend/app/routes/health.tsx b/frontend/app/routes/health.tsx new file mode 100644 index 0000000..e83a6e5 --- /dev/null +++ b/frontend/app/routes/health.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; + +export default function Health() { + const [status, setStatus] = useState<"loading" | "ok" | "error">("loading"); + const [detail, setDetail] = useState(""); + + useEffect(() => { + fetch("http://localhost:8000/healthcheck/") + .then(async (res) => { + const text = await res.text(); + setDetail(text); + setStatus(res.ok ? "ok" : "error"); + }) + .catch((err) => { + setDetail(err.message); + setStatus("error"); + }); + }, []); + + return ( +
+
+

+ Backend health check +

+

+ {status === "loading" && "Checking..."} + {status === "ok" && "✅ Backend is healthy"} + {status === "error" && "❌ Backend unreachable"} +

+ {detail && ( +
+            {detail}
+          
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/routes/login.tsx b/frontend/app/routes/login.tsx new file mode 100644 index 0000000..5d0c481 --- /dev/null +++ b/frontend/app/routes/login.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { useNavigate } from "react-router"; + +export default function Login() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const res = await fetch("http://localhost:8000/auth/token/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.detail || `Login failed (${res.status})`); + } + const data = await res.json(); + sessionStorage.setItem("access", data.access); + sessionStorage.setItem("refresh", data.refresh); + navigate("/"); + } catch (err: any) { + setError(err.message || "Something went wrong"); + } finally { + setLoading(false); + } + } + + return ( +
+
+

+ Log in +

+
+
+ + setUsername(e.target.value)} + required + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-gray-900 focus:ring-1 focus:ring-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100 dark:focus:border-gray-100 dark:focus:ring-gray-100" + /> +
+
+ + setPassword(e.target.value)} + required + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-gray-900 focus:ring-1 focus:ring-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100 dark:focus:border-gray-100 dark:focus:ring-gray-100" + /> +
+ {error &&

{error}

} + +
+

+ Don't have an account?{" "} + + Sign up + +

+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/routes/signup.tsx b/frontend/app/routes/signup.tsx new file mode 100644 index 0000000..0b05507 --- /dev/null +++ b/frontend/app/routes/signup.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { useNavigate } from "react-router"; + +export default function Signup() { + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const res = await fetch("http://localhost:8000/auth/signup/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, email, password }), + }); + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.detail || `Signup failed (${res.status})`); + } + navigate("/login"); + } catch (err: any) { + setError(err.message || "Something went wrong"); + } finally { + setLoading(false); + } + } + + return ( +
+
+

+ Sign up +

+
+
+ + setUsername(e.target.value)} + required + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-gray-900 focus:ring-1 focus:ring-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100 dark:focus:border-gray-100 dark:focus:ring-gray-100" + /> +
+
+ + setEmail(e.target.value)} + required + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-gray-900 focus:ring-1 focus:ring-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100 dark:focus:border-gray-100 dark:focus:ring-gray-100" + /> +
+
+ + setPassword(e.target.value)} + required + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-gray-900 focus:ring-1 focus:ring-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100 dark:focus:border-gray-100 dark:focus:ring-gray-100" + /> +
+ {error &&

{error}

} + +
+

+ Already have an account?{" "} + + Log in + +

+
+
+ ); +} \ No newline at end of file From df634bacf2a72aeb9f83b016a9fd4351d67b3c28 Mon Sep 17 00:00:00 2001 From: Muhammad Sadiq Ali Date: Tue, 28 Jul 2026 20:20:09 +0500 Subject: [PATCH 2/9] Add login, signup, and health check pages with React Router patterns - Add LoginForm, SignupForm, HealthCheck components - Add CenteredPageLayout and TextField reusable UI - Add lib/api.ts, auth.ts, config.ts, health.ts, types.ts - Use clientAction for login/signup, clientLoader for health check - Update routes to use React Router data APIs --- backend/apps/authentications/api/views.py | 54 ++++++- frontend/.env.example | 1 + frontend/app/components/HealthCheck.tsx | 40 +++++ frontend/app/components/LoginForm.tsx | 64 ++++++++ frontend/app/components/SignupForm.tsx | 71 +++++++++ .../components/layout/CenteredPageLayout.tsx | 26 ++++ frontend/app/components/ui/TextField.tsx | 54 +++++++ frontend/app/lib/api.ts | 65 +++++++++ frontend/app/lib/auth.ts | 56 +++++++ frontend/app/lib/config.ts | 13 ++ frontend/app/lib/health.ts | 22 +++ frontend/app/lib/types.ts | 41 ++++++ frontend/app/routes/health.tsx | 73 ++++------ frontend/app/routes/home.tsx | 2 +- frontend/app/routes/login.tsx | 123 ++++++---------- frontend/app/routes/signup.tsx | 137 ++++++------------ 16 files changed, 620 insertions(+), 222 deletions(-) create mode 100644 frontend/app/components/HealthCheck.tsx create mode 100644 frontend/app/components/LoginForm.tsx create mode 100644 frontend/app/components/SignupForm.tsx create mode 100644 frontend/app/components/layout/CenteredPageLayout.tsx create mode 100644 frontend/app/components/ui/TextField.tsx create mode 100644 frontend/app/lib/api.ts create mode 100644 frontend/app/lib/auth.ts create mode 100644 frontend/app/lib/config.ts create mode 100644 frontend/app/lib/health.ts create mode 100644 frontend/app/lib/types.ts diff --git a/backend/apps/authentications/api/views.py b/backend/apps/authentications/api/views.py index 4923907..4f63c72 100644 --- a/backend/apps/authentications/api/views.py +++ b/backend/apps/authentications/api/views.py @@ -57,27 +57,54 @@ def post(self, request): user.save(update_fields=["last_login"]) user_serializer = UserLoginSerializer(user) + access_token = login_serializer.validated_data.get("access") + refresh_token = login_serializer.validated_data.get("refresh") + data = { "user": user_serializer.data, - "token": login_serializer.validated_data, + "token": {"access": access_token}, } - return Response( + + response = Response( APIResponse.get_response( data=data, ) ) + + # Set refresh token in HttpOnly cookie + if refresh_token: + response.set_cookie( + "refresh", + refresh_token, + httponly=True, + samesite="Lax", + # secure=True # Ensure HTTPS in production + ) + + return response class RefreshTokenAPIView(APIView): permission_classes = (AllowAny,) - @swagger_auto_schema(request_body=TokenRefreshSerializer) def post(self, request): - req_data = request.data + refresh_token = request.COOKIES.get("refresh") + if not refresh_token: + return Response( + APIResponse.get_response( + message="No refresh token provided.", + ), + status=status.HTTP_401_UNAUTHORIZED, + ) + + # Passing it to serializer as 'refresh' + req_data = request.data.copy() + req_data["refresh"] = refresh_token + serializer = TokenRefreshSerializer(data=req_data) serializer.is_valid(raise_exception=True) data = { - "token": serializer.validated_data, + "token": {"access": serializer.validated_data.get("access")}, } return Response( APIResponse.get_response( @@ -155,9 +182,11 @@ def post(self, request): user = serializer.save() refresh = RefreshToken.for_user(user) + access_token = str(refresh.access_token) + refresh_token = str(refresh) + tokens = { - "refresh": str(refresh), - "access": str(refresh.access_token), + "access": access_token, } user_serializer = UserLoginSerializer(user) @@ -165,7 +194,16 @@ def post(self, request): "user": user_serializer.data, "token": tokens, } - return Response( + + response = Response( APIResponse.get_response(message="User created successfully.", data=data), status=status.HTTP_201_CREATED, ) + + response.set_cookie( + "refresh", + refresh_token, + httponly=True, + samesite="Lax", + ) + return response diff --git a/frontend/.env.example b/frontend/.env.example index e69de29..5934e2e 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:8000 diff --git a/frontend/app/components/HealthCheck.tsx b/frontend/app/components/HealthCheck.tsx new file mode 100644 index 0000000..1d28f6a --- /dev/null +++ b/frontend/app/components/HealthCheck.tsx @@ -0,0 +1,40 @@ +/** + * HealthCheck — presentational component that displays backend health status. + * + * Receives data as props from the route's clientLoader. + * No useEffect, no useState, no fetch — just renders what it's given. + */ + +import { CenteredPageLayout } from "~/components/layout/CenteredPageLayout"; + +const statusStyles: Record = { + ok: "text-green-600 dark:text-green-400", + error: "text-red-600 dark:text-red-400", +}; + +interface HealthCheckProps { + ok: boolean; + detail: string; +} + +export function HealthCheck({ ok, detail }: HealthCheckProps) { + const status = ok ? "ok" : "error"; + + return ( + +

+ Backend health check +

+ +

+ {ok ? "✅ Backend is healthy" : "❌ Backend unreachable"} +

+ + {detail && ( +
+          {detail}
+        
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/app/components/LoginForm.tsx b/frontend/app/components/LoginForm.tsx new file mode 100644 index 0000000..450e431 --- /dev/null +++ b/frontend/app/components/LoginForm.tsx @@ -0,0 +1,64 @@ +/** + * LoginForm — presentational component for the login page. + * + * All mutation logic lives in the route's clientAction. + * This component just renders the form UI and displays errors. + */ + +import { Form, Link } from "react-router"; +import { CenteredPageLayout } from "~/components/layout/CenteredPageLayout"; +import { TextField } from "~/components/ui/TextField"; + +interface LoginFormProps { + error?: string; + isSubmitting: boolean; +} + +export function LoginForm({ error, isSubmitting }: LoginFormProps) { + return ( + +

+ Log in +

+ +
+ + + + {error && ( +

{error}

+ )} + + + + +

+ Don't have an account?{" "} + + Sign up + +

+
+ ); +} \ No newline at end of file diff --git a/frontend/app/components/SignupForm.tsx b/frontend/app/components/SignupForm.tsx new file mode 100644 index 0000000..986bff3 --- /dev/null +++ b/frontend/app/components/SignupForm.tsx @@ -0,0 +1,71 @@ +/** + * SignupForm — presentational component for the signup page. + * + * All mutation logic lives in the route's clientAction. + * This component just renders the form UI and displays errors. + */ + +import { Form, Link } from "react-router"; +import { CenteredPageLayout } from "~/components/layout/CenteredPageLayout"; +import { TextField } from "~/components/ui/TextField"; + +interface SignupFormProps { + error?: string; + isSubmitting: boolean; +} + +export function SignupForm({ error, isSubmitting }: SignupFormProps) { + return ( + +

+ Sign up +

+ +
+ + + + + {error && ( +

{error}

+ )} + + + + +

+ Already have an account?{" "} + + Log in + +

+
+ ); +} \ No newline at end of file diff --git a/frontend/app/components/layout/CenteredPageLayout.tsx b/frontend/app/components/layout/CenteredPageLayout.tsx new file mode 100644 index 0000000..aa0cf1f --- /dev/null +++ b/frontend/app/components/layout/CenteredPageLayout.tsx @@ -0,0 +1,26 @@ +/** + * Shared page layout — centered card on a full-height background. + * + * Used by login, signup, health, and any future standalone pages. + */ + +interface CenteredPageLayoutProps { + children: React.ReactNode; + /** Tailwind max-width class, defaults to "max-w-sm" */ + maxWidth?: string; +} + +export function CenteredPageLayout({ + children, + maxWidth = "max-w-sm", +}: CenteredPageLayoutProps) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/frontend/app/components/ui/TextField.tsx b/frontend/app/components/ui/TextField.tsx new file mode 100644 index 0000000..b9777be --- /dev/null +++ b/frontend/app/components/ui/TextField.tsx @@ -0,0 +1,54 @@ +/** + * Reusable text input with proper accessibility. + * + * - Renders