From de3afd2b8c94b8e0f82625ed895913b2f62ab82b Mon Sep 17 00:00:00 2001 From: Kashish Singh Date: Sun, 21 Jun 2026 16:27:09 +0530 Subject: [PATCH 1/9] Added some web optimizations --- web/src/components/tasks/CreateTaskDialog.tsx | 23 ++++++------ web/src/components/tasks/EditTaskDialog.tsx | 36 ++++++++++--------- web/src/components/tasks/TaskFilters.tsx | 3 +- web/src/components/tasks/TaskForm.tsx | 4 +-- web/src/components/tasks/TaskList.tsx | 3 +- web/src/components/tasks/TaskStats.tsx | 6 ++-- web/src/features/tasks/task.types.ts | 2 +- 7 files changed, 40 insertions(+), 37 deletions(-) diff --git a/web/src/components/tasks/CreateTaskDialog.tsx b/web/src/components/tasks/CreateTaskDialog.tsx index 89ec0e1..23dbcdd 100644 --- a/web/src/components/tasks/CreateTaskDialog.tsx +++ b/web/src/components/tasks/CreateTaskDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import React, { useCallback, useState } from "react"; import { Dialog, @@ -18,6 +18,7 @@ import { useCreateTask, } from "@/features/tasks/task.hooks"; import { Plus } from "lucide-react"; +import type { CreateTaskPayload } from "@/features/tasks/task.types"; const CreateTaskDialog = () => { @@ -26,7 +27,13 @@ const CreateTaskDialog = () => { const mutation = useCreateTask(); - +const handleSubmit = useCallback( + async (values: CreateTaskPayload) => { + await mutation.mutateAsync(values); + setOpen(false); + }, + [mutation, setOpen] +); return ( { isSubmitting={ mutation.isPending } - onSubmit={async ( - values - ) => { - await mutation.mutateAsync( - values - ); - - setOpen( - false - ); - }} + onSubmit={handleSubmit} /> diff --git a/web/src/components/tasks/EditTaskDialog.tsx b/web/src/components/tasks/EditTaskDialog.tsx index 17a1f6c..80035ce 100644 --- a/web/src/components/tasks/EditTaskDialog.tsx +++ b/web/src/components/tasks/EditTaskDialog.tsx @@ -1,4 +1,5 @@ -import { +import React, { + useCallback, useState, } from "react"; @@ -16,6 +17,7 @@ import { import type { Task, + UpdateTaskPayload, } from "@/features/tasks/task.types"; import TaskForm from "./TaskForm"; @@ -37,7 +39,21 @@ const EditTaskDialog = ({ const mutation = useUpdateTask(); + const submitHandler = useCallback(async ( + values:UpdateTaskPayload + ) => { + await mutation.mutateAsync( + { + id: task._id, + payload: + values, + } + ); + setOpen( + false + ); + },[setOpen,mutation]); return ( { - await mutation.mutateAsync( - { - id: task._id, - payload: - values, - } - ); - - setOpen( - false - ); - }} + onSubmit={submitHandler} /> ); }; -export default EditTaskDialog; \ No newline at end of file +export default React.memo(EditTaskDialog); \ No newline at end of file diff --git a/web/src/components/tasks/TaskFilters.tsx b/web/src/components/tasks/TaskFilters.tsx index b6a8517..ef4761d 100644 --- a/web/src/components/tasks/TaskFilters.tsx +++ b/web/src/components/tasks/TaskFilters.tsx @@ -9,6 +9,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import React from "react"; const TaskFilters = () => { @@ -141,4 +142,4 @@ const TaskFilters = () => { ); }; -export default TaskFilters; \ No newline at end of file +export default React.memo(TaskFilters); \ No newline at end of file diff --git a/web/src/components/tasks/TaskForm.tsx b/web/src/components/tasks/TaskForm.tsx index d3c56ef..1b5c506 100644 --- a/web/src/components/tasks/TaskForm.tsx +++ b/web/src/components/tasks/TaskForm.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import React, { useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -189,4 +189,4 @@ const TaskForm = ({ ); }; -export default TaskForm; +export default React.memo(TaskForm); diff --git a/web/src/components/tasks/TaskList.tsx b/web/src/components/tasks/TaskList.tsx index cb80ca2..f1570bf 100644 --- a/web/src/components/tasks/TaskList.tsx +++ b/web/src/components/tasks/TaskList.tsx @@ -8,6 +8,7 @@ import { import type { Task, } from "@/features/tasks/task.types"; +import React from "react"; interface Props { tasks?: Task[]; @@ -74,4 +75,4 @@ const TaskList = ({ ); }; -export default TaskList; \ No newline at end of file +export default React.memo(TaskList); \ No newline at end of file diff --git a/web/src/components/tasks/TaskStats.tsx b/web/src/components/tasks/TaskStats.tsx index 9d9fed3..baecc97 100644 --- a/web/src/components/tasks/TaskStats.tsx +++ b/web/src/components/tasks/TaskStats.tsx @@ -24,6 +24,7 @@ import { import type { TaskStats as TaskStatsType, } from "../../types/task"; +import React from "react"; const EMPTY_STATS: TaskStatsType = { total: 0, @@ -35,7 +36,7 @@ const EMPTY_STATS: TaskStatsType = { completionRate: 0, }; -export default function TaskStats() { +function TaskStats() { const [ searchParams, setSearchParams, @@ -341,4 +342,5 @@ export default function TaskStats() { )} ); -} \ No newline at end of file +} +export default React.memo(TaskStats); \ No newline at end of file diff --git a/web/src/features/tasks/task.types.ts b/web/src/features/tasks/task.types.ts index 7d8fadd..a7dd5a0 100644 --- a/web/src/features/tasks/task.types.ts +++ b/web/src/features/tasks/task.types.ts @@ -53,4 +53,4 @@ export interface TaskFiltersInterface { sortBy?: "createdAt" | "dueDate"; sortOrder?: "asc" | "desc"; -} \ No newline at end of file +} From 3e5226246f2df81702b4be0d89dd397cad077e48 Mon Sep 17 00:00:00 2001 From: Kashish Singh Date: Sun, 21 Jun 2026 16:29:59 +0530 Subject: [PATCH 2/9] Removed unused import --- web/src/components/tasks/CreateTaskDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/tasks/CreateTaskDialog.tsx b/web/src/components/tasks/CreateTaskDialog.tsx index 23dbcdd..9a8b444 100644 --- a/web/src/components/tasks/CreateTaskDialog.tsx +++ b/web/src/components/tasks/CreateTaskDialog.tsx @@ -69,4 +69,4 @@ const handleSubmit = useCallback( ); }; -export default CreateTaskDialog; \ No newline at end of file +export default React.memo(CreateTaskDialog); \ No newline at end of file From 2e60b02cf5b41e9016e0c8240e745f911075bc51 Mon Sep 17 00:00:00 2001 From: Kashish Singh Date: Tue, 23 Jun 2026 14:58:37 +0530 Subject: [PATCH 3/9] Feat:(Seerver-Refresh token rotation with route, Signout route) --- server/src/constants/auth.constants.ts | 3 +- server/src/controllers/auth.controllers.ts | 105 +++++++++----- server/src/routes/auth.routes.ts | 8 +- server/src/services/auth.services.ts | 112 +++++++-------- server/src/utils/issueTokens.ts | 59 -------- server/src/utils/jwt.ts | 30 ++-- server/src/utils/tokenHash.ts | 10 -- server/src/utils/tokenUtils.ts | 153 +++++++++++++++++++++ web/src/components/layout/AppShell.tsx | 2 +- web/src/components/layout/Navbar.tsx | 6 +- web/src/components/tasks/TaskCard.tsx | 4 +- web/src/features/auth/AuthForm.tsx | 2 +- web/src/features/auth/auth.api.ts | 2 +- 13 files changed, 300 insertions(+), 196 deletions(-) delete mode 100644 server/src/utils/issueTokens.ts delete mode 100644 server/src/utils/tokenHash.ts create mode 100644 server/src/utils/tokenUtils.ts diff --git a/server/src/constants/auth.constants.ts b/server/src/constants/auth.constants.ts index a43cdb8..062e731 100644 --- a/server/src/constants/auth.constants.ts +++ b/server/src/constants/auth.constants.ts @@ -1 +1,2 @@ -export const MAX_REFRESH_TOKENS = 5; \ No newline at end of file +export const MAX_REFRESH_TOKENS = 5; +export const REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60 * 1000; \ No newline at end of file diff --git a/server/src/controllers/auth.controllers.ts b/server/src/controllers/auth.controllers.ts index 91c75de..74fd941 100644 --- a/server/src/controllers/auth.controllers.ts +++ b/server/src/controllers/auth.controllers.ts @@ -1,35 +1,21 @@ import { Request, Response } from "express"; - -import { - registerUser, - loginUser, -} from "../services/auth.services"; - -import { - RegisterUserInput, - LoginUserInput, -} from "../validators/auth.validator"; - +import {registerUser,loginUser,deleteAndCreateRefreshToken, signoutUser,} from "../services/auth.services"; +import {RegisterUserInput,LoginUserInput} from "../validators/auth.validator"; import { refreshCookieOptions } from "../utils/cookies"; import { asyncHandler } from "../utils/asyncHandler"; +import { verifyRefreshToken } from "../utils/jwt"; +import UserModel from "../models/user.model"; +import { hashRefreshToken, rotateRefreshToken } from "../utils/tokenUtils"; +import AppError from "../utils/AppError"; export const registerController = - asyncHandler (async( + asyncHandler ( + async( req: Request, res: Response ) => { - const result = - await registerUser( - req.validated - ?.body as RegisterUserInput - ); - - res.cookie( - "refreshToken", - result.refreshToken, - refreshCookieOptions - ); - + const result = await registerUser(req.validated?.body as RegisterUserInput); + res.cookie("refreshToken",result.refreshToken,refreshCookieOptions); res.status(201).json({ success: true, data: { @@ -38,19 +24,15 @@ export const registerController = result.accessToken, }, }); - }); + } +); export const loginController = asyncHandler( async ( req: Request, res: Response ) => { - const result = - await loginUser( - req.validated - ?.body as LoginUserInput - ); - + const result = await loginUser(req.validated?.body as LoginUserInput); res.cookie( "refreshToken", result.refreshToken, @@ -65,4 +47,63 @@ export const loginController = asyncHandler( result.accessToken, }, }); - }); \ No newline at end of file + }); + + export const meController = asyncHandler(async(req:Request,res:Response)=>{ + res.json(req.auth) + }) + +export const refreshAuthController = asyncHandler( + async (req: Request, res: Response) => { + const oldRefreshToken = req.cookies["refreshToken"]; + if (!oldRefreshToken) { + throw new AppError("No refresh token", 401); + } + // 1) Verify the refresh token signature & payload + const payload = verifyRefreshToken(oldRefreshToken); + // 2) Hash the presented token + const oldHash = hashRefreshToken(oldRefreshToken); + // 3) Make sure this user exists and still has this non‑expired token + const user = await UserModel.findOne({ + _id: payload.userId, + refreshTokens: { + $elemMatch: { + tokenHash: oldHash, + expiresAt: { $gt: new Date() }, + }, + }, + }); + if (!user) { + throw new AppError("Invalid or expired refresh token", 401); + } + // 4) Atomically rotate the refresh token in DB and issue new tokens + const { accessToken, refreshToken } = await rotateRefreshToken( + oldHash, + user._id + ); + // 5) Update cookie with the new refresh token + res.cookie("refreshToken", refreshToken, refreshCookieOptions); + // 6) Return new access token + user + res.status(200).json({ + message: "Refresh completed", + data: { + accessToken, + }, + }); + } +); + +export const signoutController = asyncHandler( + async (req: Request, res: Response) => { + const userId = req.auth.userId; + const refreshToken = req.cookies["refreshToken"]; + if (refreshToken) { + await signoutUser(userId, refreshToken); + } + res.cookie("refreshToken", "", { + ...refreshCookieOptions, + maxAge: 0, + }); + res.status(204).send(); + } +); diff --git a/server/src/routes/auth.routes.ts b/server/src/routes/auth.routes.ts index ec4efa9..f49e627 100644 --- a/server/src/routes/auth.routes.ts +++ b/server/src/routes/auth.routes.ts @@ -4,7 +4,8 @@ import validate from "../middleware/validate.middleware"; import {registerUserSchema,loginUserSchema} from "../validators/auth.validator"; -import {registerController,loginController} from "../controllers/auth.controllers"; +import {registerController,loginController, meController, refreshAuthController, signoutController} from "../controllers/auth.controllers"; +import authMiddleware from "../middleware/auth.middleware"; const router = Router(); @@ -13,4 +14,9 @@ router.post( router.post("/login",validate(loginUserSchema,"body"),loginController); +router.get("/me",authMiddleware,meController) + +router.post("/refresh",refreshAuthController) + +router.post("/signout",authMiddleware,signoutController); export default router; \ No newline at end of file diff --git a/server/src/services/auth.services.ts b/server/src/services/auth.services.ts index 4a5a2a6..5a390e5 100644 --- a/server/src/services/auth.services.ts +++ b/server/src/services/auth.services.ts @@ -1,58 +1,25 @@ import UserModel from "../models/user.model"; - import AppError from "../utils/AppError"; - import { hashPassword, comparePassword, } from "../utils/password"; - -import { issueTokens } from "../utils/issueTokens"; - +import { issueTokens, storeRefreshToken } from "../utils/tokenUtils"; import { buildAuthResponse } from "../utils/authResponse"; - -import type { - RegisterUserInput, - LoginUserInput, -} from "../validators/auth.validator"; - -export const registerUser = async ( - input: RegisterUserInput -) => { - const existingUser = - await UserModel.findOne({ - email: input.email, - }); - - if (existingUser) { - throw new AppError( - "User already exists", - 409 - ); - } - - const passwordHash = - await hashPassword( - input.password - ); - +import type {RegisterUserInput,LoginUserInput,JwtPayload} from "../validators/auth.validator"; +import { ObjectId } from "mongoose"; +import { generateRefreshToken } from "../utils/jwt"; +import { hashRefreshToken } from "../utils/tokenUtils"; + +export const registerUser = async (input: RegisterUserInput) => { + const existingUser = await UserModel.findOne({ email: input.email}); + if (existingUser) {throw new AppError("User already exists",409)} + const passwordHash = await hashPassword(input.password); try { - const user = - await UserModel.create({ - email: input.email, - passwordHash, - }); - - const { - accessToken, - refreshToken, - } = await issueTokens(user); - - return buildAuthResponse( - user, - accessToken, - refreshToken - ); + const user = await UserModel.create({email: input.email,passwordHash}); + const {accessToken,refreshToken} = await issueTokens(user); + await storeRefreshToken(user._id.toString(),refreshToken); + return buildAuthResponse(user,accessToken,refreshToken); } catch (error) { if ( typeof error === "object" && @@ -65,7 +32,6 @@ export const registerUser = async ( 409 ); } - throw error; } }; @@ -73,39 +39,59 @@ export const registerUser = async ( export const loginUser = async ( input: LoginUserInput ) => { - const user = - await UserModel.findOne({ - email: input.email, - }); - + const user = await UserModel.findOne({email: input.email}); if (!user) { throw new AppError( "Invalid credentials", 401 ); } - - const passwordValid = - await comparePassword( - input.password, - user.passwordHash - ); - + const passwordValid = await comparePassword(input.password,user.passwordHash); if (!passwordValid) { throw new AppError( "Invalid credentials", 401 ); } - const { accessToken, refreshToken, } = await issueTokens(user); - + await storeRefreshToken(user._id.toString(),refreshToken); return buildAuthResponse( user, accessToken, refreshToken ); -}; \ No newline at end of file +}; + +export const deleteAndCreateRefreshToken = async (oldRefreshTokenHash:string,user:InstanceType)=>{ + const {accessToken,refreshToken,} = await issueTokens(user) + let result = await UserModel.findByIdAndUpdate( + { + _id: user._id, + refreshTokens: + oldRefreshTokenHash, + }, + { + $pull: { + refreshTokens: + oldRefreshTokenHash, + } + } +); +return {accessToken,refreshToken}; +} + +export const signoutUser = async (userId: string, refreshToken: string) => { + if (!refreshToken) return; + const tokenHash = hashRefreshToken(refreshToken); + await UserModel.updateOne( + { _id: userId }, + { + $pull: { + refreshTokens: { tokenHash }, + }, + } + ); +}; diff --git a/server/src/utils/issueTokens.ts b/server/src/utils/issueTokens.ts deleted file mode 100644 index 75b4018..0000000 --- a/server/src/utils/issueTokens.ts +++ /dev/null @@ -1,59 +0,0 @@ -import UserModel from "../models/user.model"; - -import { - generateAccessToken, - generateRefreshToken, -} from "./jwt"; - -import { hashRefreshToken } from "./tokenHash"; - -import { MAX_REFRESH_TOKENS } from "../constants/auth.constants"; - -const REFRESH_TOKEN_TTL = - 7 * 24 * 60 * 60 * 1000; - -export const issueTokens = async ( - user: InstanceType -) => { - const payload = { - userId: user._id.toString(), - email: user.email, - }; - - const accessToken = - generateAccessToken(payload); - - const refreshToken = - generateRefreshToken(payload); - - const tokenHash = - hashRefreshToken(refreshToken); - - user.refreshTokens = - user.refreshTokens.filter( - (token) => - token.expiresAt > new Date() - ); - - if ( - user.refreshTokens.length >= - MAX_REFRESH_TOKENS - ) { - user.refreshTokens.shift(); - } - - user.refreshTokens.push({ - tokenHash, - expiresAt: new Date( - Date.now() + - REFRESH_TOKEN_TTL - ), - }); - - await user.save(); - - return { - accessToken, - refreshToken, - }; -}; \ No newline at end of file diff --git a/server/src/utils/jwt.ts b/server/src/utils/jwt.ts index 52be461..30cb734 100644 --- a/server/src/utils/jwt.ts +++ b/server/src/utils/jwt.ts @@ -1,16 +1,8 @@ -import jwt, { - SignOptions, -} from "jsonwebtoken"; - +import jwt, {SignOptions} from "jsonwebtoken"; import { env } from "../config/env"; +import type {JwtPayload} from "../types/auth.types"; -import type { - JwtPayload, -} from "../types/auth.types"; - -export const generateAccessToken = ( - payload: JwtPayload -): string => { +export const generateAccessToken = (payload: JwtPayload): string => { return jwt.sign( payload, env.JWT_ACCESS_SECRET, @@ -21,9 +13,7 @@ export const generateAccessToken = ( ); }; -export const generateRefreshToken = ( - payload: JwtPayload -): string => { +export const generateRefreshToken = (payload: JwtPayload): string => { return jwt.sign( payload, env.JWT_REFRESH_SECRET, @@ -34,20 +24,16 @@ export const generateRefreshToken = ( ); }; -export const verifyAccessToken = ( - token: string -): unknown => { +export const verifyAccessToken = (token: string): JwtPayload => { return jwt.verify( token, env.JWT_ACCESS_SECRET - ); + ) as JwtPayload; }; -export const verifyRefreshToken = ( - token: string -): unknown => { +export const verifyRefreshToken = (token: string): JwtPayload => { return jwt.verify( token, env.JWT_REFRESH_SECRET - ); + ) as JwtPayload; }; \ No newline at end of file diff --git a/server/src/utils/tokenHash.ts b/server/src/utils/tokenHash.ts deleted file mode 100644 index b595508..0000000 --- a/server/src/utils/tokenHash.ts +++ /dev/null @@ -1,10 +0,0 @@ -import crypto from "crypto"; - -export const hashRefreshToken = ( - token: string -): string => { - return crypto - .createHash("sha256") - .update(token) - .digest("hex"); -}; \ No newline at end of file diff --git a/server/src/utils/tokenUtils.ts b/server/src/utils/tokenUtils.ts new file mode 100644 index 0000000..67f73d8 --- /dev/null +++ b/server/src/utils/tokenUtils.ts @@ -0,0 +1,153 @@ +import UserModel from "../models/user.model"; +import crypto from "crypto"; +import {generateAccessToken,generateRefreshToken} from "./jwt"; +import { MAX_REFRESH_TOKENS, REFRESH_TOKEN_TTL } from "../constants/auth.constants"; +import { Types } from "mongoose"; +import AppError from "./AppError"; + +const hashRefreshToken = (token: string): string => { + return crypto + .createHash("sha256") + .update(token) + .digest("hex"); +}; + +const issueTokens = async ( + user: InstanceType +) => { + const payload = { + userId: user._id.toString(), + email: user.email, + }; + return { + accessToken:generateAccessToken(payload), + refreshToken:generateRefreshToken(payload), + }; +}; + +const storeRefreshToken = async ( + userId: string, + refreshToken: string +) => { + const tokenHash = hashRefreshToken(refreshToken); + const now = new Date(); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL); + + return await UserModel.updateOne( + { _id: userId }, + [ + { + $set: { + refreshTokens: { + $filter: { + input: "$refreshTokens", + as: "t", + cond: { $gt: ["$$t.expiresAt", now] }, + }, + }, + }, + }, + { + $set: { + refreshTokens: { + $slice: ["$refreshTokens", -Math.max(MAX_REFRESH_TOKENS - 1, 0)], + }, + }, + }, + { + $set: { + refreshTokens: { + $concatArrays: [ + "$refreshTokens", + [ + { + tokenHash, + expiresAt, + }, + ], + ], + }, + }, + }, + ], + { updatePipeline: true } // <-- important + ); +}; + + +const rotateRefreshToken = async ( + oldRefreshTokenHash: string, + userId: Types.ObjectId | string +) => { + // Issue new tokens first (pure operation) + const user = await UserModel.findById(userId); + if (!user) { + throw new AppError("User not found", 404); + } + + const { accessToken, refreshToken } = await issueTokens(user); + + const newTokenHash = hashRefreshToken(refreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL); + + // Atomically: require old token, drop it, trim, and add new one + const result = await UserModel.updateOne( + { + _id: user._id, + "refreshTokens.tokenHash": oldRefreshTokenHash, + }, + [ + // drop the old token and any expired ones (optional but nice) + { + $set: { + refreshTokens: { + $filter: { + input: "$refreshTokens", + as: "t", + cond: { + $and: [ + { $ne: ["$$t.tokenHash", oldRefreshTokenHash] }, + { $gt: ["$$t.expiresAt", new Date()] }, + ], + }, + }, + }, + }, + }, + // trim to max-1 to make space + { + $set: { + refreshTokens: { + $slice: ["$refreshTokens", -Math.max(MAX_REFRESH_TOKENS - 1, 0)], + }, + }, + }, + // append new token + { + $set: { + refreshTokens: { + $concatArrays: [ + "$refreshTokens", + [ + { + tokenHash: newTokenHash, + expiresAt, + }, + ], + ], + }, + }, + }, + ], + { updatePipeline: true } + ); + + if (result.modifiedCount === 0) { + // old token didn't exist or user changed between findById and update + throw new AppError("Invalid refresh token", 401); + } + + return { accessToken, refreshToken }; +}; + +export {issueTokens,hashRefreshToken,storeRefreshToken,rotateRefreshToken} \ No newline at end of file diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 9957b7e..8f169ce 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -4,7 +4,7 @@ import Navbar from "./Navbar"; export default function AppShell() { return ( -
+
diff --git a/web/src/components/layout/Navbar.tsx b/web/src/components/layout/Navbar.tsx index 7c2c10c..d0c2d0e 100644 --- a/web/src/components/layout/Navbar.tsx +++ b/web/src/components/layout/Navbar.tsx @@ -89,15 +89,15 @@ export default function Navbar() { {user && ( - - + diff --git a/web/src/components/tasks/TaskCard.tsx b/web/src/components/tasks/TaskCard.tsx index 2bf4929..10f32c2 100644 --- a/web/src/components/tasks/TaskCard.tsx +++ b/web/src/components/tasks/TaskCard.tsx @@ -87,8 +87,8 @@ const TaskCard = ({ -
- +
+ ; ? "Account created successfully" : "Logged in successfully" ); - + console.log(response) navigate("/dashboard", { replace: true }); } catch (error: any) { const message = diff --git a/web/src/features/auth/auth.api.ts b/web/src/features/auth/auth.api.ts index 7cf0f37..4f4df9a 100644 --- a/web/src/features/auth/auth.api.ts +++ b/web/src/features/auth/auth.api.ts @@ -34,7 +34,7 @@ export const loginUser = "/auth/login", payload ); - + console.log(response); return response.data; }; From c58404e20186293e59b192e023f43c15f604f154 Mon Sep 17 00:00:00 2001 From: Kashish Singh Date: Tue, 23 Jun 2026 15:45:05 +0530 Subject: [PATCH 4/9] Feat:(Signout-all server) --- server/src/controllers/auth.controllers.ts | 12 +++++++++++- server/src/routes/auth.routes.ts | 4 +++- server/src/services/auth.services.ts | 6 +++++- server/src/services/task.services.ts | 3 ++- server/src/utils/tokenUtils.ts | 9 ++++++++- 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/server/src/controllers/auth.controllers.ts b/server/src/controllers/auth.controllers.ts index 74fd941..28edf14 100644 --- a/server/src/controllers/auth.controllers.ts +++ b/server/src/controllers/auth.controllers.ts @@ -1,5 +1,5 @@ import { Request, Response } from "express"; -import {registerUser,loginUser,deleteAndCreateRefreshToken, signoutUser,} from "../services/auth.services"; +import {registerUser,loginUser,deleteAndCreateRefreshToken, signoutUser, signoutAllService,} from "../services/auth.services"; import {RegisterUserInput,LoginUserInput} from "../validators/auth.validator"; import { refreshCookieOptions } from "../utils/cookies"; import { asyncHandler } from "../utils/asyncHandler"; @@ -107,3 +107,13 @@ export const signoutController = asyncHandler( res.status(204).send(); } ); + +export const signoutAllController = asyncHandler(async(req:Request,res:Response)=>{ + const userId = req.auth!.userId; + await signoutAllService(userId); + res.cookie("refreshToken", "", { + ...refreshCookieOptions, + maxAge: 0, + }); + res.status(204).send() +}) diff --git a/server/src/routes/auth.routes.ts b/server/src/routes/auth.routes.ts index f49e627..28dc4cc 100644 --- a/server/src/routes/auth.routes.ts +++ b/server/src/routes/auth.routes.ts @@ -4,7 +4,7 @@ import validate from "../middleware/validate.middleware"; import {registerUserSchema,loginUserSchema} from "../validators/auth.validator"; -import {registerController,loginController, meController, refreshAuthController, signoutController} from "../controllers/auth.controllers"; +import {registerController,loginController, meController, refreshAuthController, signoutController, signoutAllController} from "../controllers/auth.controllers"; import authMiddleware from "../middleware/auth.middleware"; const router = Router(); @@ -19,4 +19,6 @@ router.get("/me",authMiddleware,meController) router.post("/refresh",refreshAuthController) router.post("/signout",authMiddleware,signoutController); +router.post("/signout-all",authMiddleware,signoutAllController); + export default router; \ No newline at end of file diff --git a/server/src/services/auth.services.ts b/server/src/services/auth.services.ts index 5a390e5..5493e64 100644 --- a/server/src/services/auth.services.ts +++ b/server/src/services/auth.services.ts @@ -4,7 +4,7 @@ import { hashPassword, comparePassword, } from "../utils/password"; -import { issueTokens, storeRefreshToken } from "../utils/tokenUtils"; +import { issueTokens, revokeAllRefreshTokens, storeRefreshToken } from "../utils/tokenUtils"; import { buildAuthResponse } from "../utils/authResponse"; import type {RegisterUserInput,LoginUserInput,JwtPayload} from "../validators/auth.validator"; import { ObjectId } from "mongoose"; @@ -95,3 +95,7 @@ export const signoutUser = async (userId: string, refreshToken: string) => { } ); }; + +export const signoutAllService = async(userId:string)=>{ + return await revokeAllRefreshTokens(userId); +} \ No newline at end of file diff --git a/server/src/services/task.services.ts b/server/src/services/task.services.ts index 3b49ee5..d3b1c64 100644 --- a/server/src/services/task.services.ts +++ b/server/src/services/task.services.ts @@ -114,4 +114,5 @@ export const getTasks = async ( [sortField]: sortOrder, }) .lean(); -}; \ No newline at end of file +}; + diff --git a/server/src/utils/tokenUtils.ts b/server/src/utils/tokenUtils.ts index 67f73d8..44e64e6 100644 --- a/server/src/utils/tokenUtils.ts +++ b/server/src/utils/tokenUtils.ts @@ -150,4 +150,11 @@ const rotateRefreshToken = async ( return { accessToken, refreshToken }; }; -export {issueTokens,hashRefreshToken,storeRefreshToken,rotateRefreshToken} \ No newline at end of file +const revokeAllRefreshTokens = async (userId: string): Promise => { + await UserModel.updateOne( + { _id: userId }, + { $set: { refreshTokens: [] } } + ); +}; + +export {issueTokens,hashRefreshToken,storeRefreshToken,rotateRefreshToken,revokeAllRefreshTokens} \ No newline at end of file From ecc792ea214171a128921e715b0edd83688ed88a Mon Sep 17 00:00:00 2001 From: Kashish Singh Date: Wed, 24 Jun 2026 23:58:20 +0530 Subject: [PATCH 5/9] feat(auth): add jwt access/refresh token flow with axios interceptors, redux bootstrap, and protected routes --- server/src/controllers/auth.controllers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/controllers/auth.controllers.ts b/server/src/controllers/auth.controllers.ts index 28edf14..f9d75c6 100644 --- a/server/src/controllers/auth.controllers.ts +++ b/server/src/controllers/auth.controllers.ts @@ -50,7 +50,8 @@ export const loginController = asyncHandler( }); export const meController = asyncHandler(async(req:Request,res:Response)=>{ - res.json(req.auth) + res.set("Cache-Control", "no-store"); + res.json(req.auth) }) export const refreshAuthController = asyncHandler( From 7d7382cf1a01ec8490314ef6be2f3b8929a58080 Mon Sep 17 00:00:00 2001 From: Kashish Singh Date: Wed, 24 Jun 2026 23:58:45 +0530 Subject: [PATCH 6/9] feat(auth): add jwt access/refresh token flow with axios interceptors, redux bootstrap, and protected routes --- web/src/App.tsx | 26 ++++- web/src/api/client.ts | 102 ++++++++++-------- web/src/components/layout/ErrorBoundary.tsx | 22 +--- web/src/components/layout/Navbar.tsx | 21 +++- web/src/components/ui/square-spin.tsx | 13 +++ web/src/features/auth/AuthForm.tsx | 18 ++-- web/src/features/auth/auth.api.ts | 97 ++++++++---------- web/src/features/auth/auth.hooks.ts | 52 ++++++---- web/src/features/auth/auth.selectors.ts | 0 web/src/features/auth/auth.slice.ts | 108 +++++--------------- web/src/features/auth/auth.ts | 29 ++++++ web/src/features/auth/auth.types.ts | 0 web/src/hooks/useAppDispatch.ts | 11 +- web/src/main.tsx | 39 ++----- web/src/pages/NotFound.tsx | 13 +++ web/src/routes/AppRouter.tsx | 33 ++---- web/src/routes/ProtectedRoute.tsx | 31 +++--- web/src/routes/PublicOnlyRoute.tsx | 22 ++++ web/src/store/index.ts | 22 +--- web/src/types/auth.ts | 0 web/src/types/axios.d.ts | 7 ++ web/src/types/common.types.ts | 0 web/src/utils/authHelper.ts | 60 +++++++++++ 23 files changed, 390 insertions(+), 336 deletions(-) create mode 100644 web/src/components/ui/square-spin.tsx delete mode 100644 web/src/features/auth/auth.selectors.ts create mode 100644 web/src/features/auth/auth.ts delete mode 100644 web/src/features/auth/auth.types.ts create mode 100644 web/src/pages/NotFound.tsx create mode 100644 web/src/routes/PublicOnlyRoute.tsx delete mode 100644 web/src/types/auth.ts create mode 100644 web/src/types/axios.d.ts delete mode 100644 web/src/types/common.types.ts create mode 100644 web/src/utils/authHelper.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index f4b7203..98b36af 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,9 +1,29 @@ import ErrorBoundary from "./components/layout/ErrorBoundary"; -import AppRouter - from "./routes/AppRouter"; +import AppRouter from "./routes/AppRouter"; +import { useEffect,useRef } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import type { RootState } from "./store"; +import { bootstrapAuthFlow } from "@/features/auth/auth.api"; +import { SquareSpin } from "./components/ui/square-spin"; function App() { - + const dispatch = useDispatch(); + const isBootstrapping = useSelector( + (state: RootState) => state.auth.isBootstrapping + ); + const hasBootstrapped = useRef(false) + useEffect(() => { + if(hasBootstrapped.current)return + hasBootstrapped.current=true + dispatch(bootstrapAuthFlow() as any); + }, [dispatch]); + if (isBootstrapping) { + return ( +
+ +
+ ); + } return ( diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1bc236f..009324b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,56 +1,68 @@ -import axios from "axios"; - -export const apiClient = - axios.create({ - baseURL: - import.meta.env - .VITE_API_URL, - - headers: { - "Content-Type": - "application/json", - }, - }); - -apiClient.interceptors.request.use( - (config) => { - const token = - localStorage.getItem( - "accessToken" - ); - - if (token) { - config.headers.Authorization = - `Bearer ${token}`; - } +// src/api/client.ts +import axios, { AxiosError } from "axios"; +import { authStorage, refreshAccessToken } from "@/utils/authHelper"; +import { store } from "@/store"; +import { setAccessToken, logout } from "@/features/auth/auth.slice"; + +export const apiClient = axios.create({baseURL: import.meta.env.VITE_API_URL,withCredentials: true, + headers: {"Content-Type": "application/json",},}); - return config; +apiClient.interceptors.request.use((config) => { + const token = authStorage.getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; } -); + return config; +}); -apiClient.interceptors.response.use( - (response) => response, +let isRefreshing = false; +let pendingRequests: ((token: string | null) => void)[] = []; - (error) => { +const processQueue = (token: string | null) => { + pendingRequests.forEach((cb) => cb(token)); + pendingRequests = []; +}; - if ( - error.response?.status === 401 - ) { +apiClient.interceptors.response.use((response) => response, async (error: AxiosError) => { + const originalRequest: any = error.config; + const status = error.response?.status; + const url = originalRequest?.url as string | undefined; + const isRefreshCall = url?.includes("/auth/refresh"); - localStorage.removeItem( - "accessToken" - ); + if (status === 401 && !originalRequest._retry && !isRefreshCall) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + pendingRequests.push((newToken) => { + if (!newToken) { + reject(error); + return; + } + originalRequest.headers.Authorization = `Bearer ${newToken}`; + resolve(apiClient(originalRequest)); + }); + }); + } - localStorage.removeItem( - "user" - ); + originalRequest._retry = true; + isRefreshing = true; - window.location.href = - "/login"; + try { + const newToken = await refreshAccessToken(); + store.dispatch(setAccessToken(newToken)); + processQueue(newToken); + + originalRequest.headers.Authorization = `Bearer ${newToken}`; + return apiClient(originalRequest); + } catch (e) { + processQueue(null); + authStorage.clear(); + store.dispatch(logout()); + return Promise.reject(e); + } finally { + isRefreshing = false; + } } - return Promise.reject( - error - ); + return Promise.reject(error); } -); \ No newline at end of file +); diff --git a/web/src/components/layout/ErrorBoundary.tsx b/web/src/components/layout/ErrorBoundary.tsx index 1ebb29e..4704da5 100644 --- a/web/src/components/layout/ErrorBoundary.tsx +++ b/web/src/components/layout/ErrorBoundary.tsx @@ -1,50 +1,32 @@ -import { - Component, - type ReactNode, -} from "react"; - +import {Component,type ReactNode} from "react"; interface Props { children: ReactNode; } - interface State { hasError: boolean; } - -class ErrorBoundary - extends Component< - Props, - State - > { - +class ErrorBoundary extends Component { state = { hasError: false, }; - static getDerivedStateFromError() { - return { hasError: true, }; } - render() { - if ( this.state .hasError ) { return (
-

Something went wrong

-
); } - return this.props.children; } } diff --git a/web/src/components/layout/Navbar.tsx b/web/src/components/layout/Navbar.tsx index d0c2d0e..5e1f060 100644 --- a/web/src/components/layout/Navbar.tsx +++ b/web/src/components/layout/Navbar.tsx @@ -17,7 +17,8 @@ import { useAppDispatch } from "@/hooks/useAppDispatch"; import { useAppSelector } from "@/hooks/useAppSelector"; import { logout } from "@/features/auth/auth.slice"; - +import { authStorage } from "@/utils/authHelper"; +import { useLogout } from "@/features/auth/auth.hooks"; export default function Navbar() { const navigate = useNavigate(); @@ -30,12 +31,13 @@ export default function Navbar() { ); const handleLogout = () => { + authStorage.clear() dispatch(logout()); - navigate("/", { replace: true, }); }; + const handleLogoutF = useLogout(); return (
@@ -94,15 +96,24 @@ export default function Navbar() { - + handleLogoutF("current")} + className="p-2 cursor-pointer border-b border-gray-600 rounded-none hover:bg-gray-200 dark:hover:bg-stone-800 flex items-center whitespace-nowrap" + > Logout + handleLogoutF("all")} + className="p-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-stone-800 flex items-center whitespace-nowrap" + > + + + Logout All Devices + )} diff --git a/web/src/components/ui/square-spin.tsx b/web/src/components/ui/square-spin.tsx new file mode 100644 index 0000000..8fd987a --- /dev/null +++ b/web/src/components/ui/square-spin.tsx @@ -0,0 +1,13 @@ +type LoaderProps = { + size?: 'sm' | 'md' | 'lg' | 'xl'; + className?: string; +}; + +export function SquareSpin({ size = 'xl', className = '' }: LoaderProps) { + const sizeClasses = { sm: 'w-4 h-4', md: 'w-8 h-8', lg: 'w-12 h-12', xl: 'w-16 h-16' }; + const containerSize = sizeClasses[size] || sizeClasses.md; + const primaryBgClass = "bg-zinc-950 dark:bg-zinc-50"; + return ( +
+ ); +} diff --git a/web/src/features/auth/AuthForm.tsx b/web/src/features/auth/AuthForm.tsx index 3ac572d..f0b1b4d 100644 --- a/web/src/features/auth/AuthForm.tsx +++ b/web/src/features/auth/AuthForm.tsx @@ -6,15 +6,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner"; import { useAppDispatch } from "@/hooks/useAppDispatch"; -import { loginSuccess } from "@/features/auth/auth.slice"; +import { setCredentials } from "@/features/auth/auth.slice"; import { useLogin, useRegister } from "@/features/auth/auth.hooks"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "../../components/ui/card"; +import {Card,CardContent,CardHeader,CardTitle} from "../../components/ui/card"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { Button } from "../../components/ui/button"; @@ -88,14 +83,14 @@ type FormData = z.infer; password: values.password, }; - const response = isRegister + const {user,accessToken} = isRegister ? await registerMutation.mutateAsync(payload) : await loginMutation.mutateAsync(payload); dispatch( - loginSuccess({ - user: response.data.user, - accessToken: response.data.accessToken, + setCredentials({ + user, + accessToken }) ); @@ -104,7 +99,6 @@ type FormData = z.infer; ? "Account created successfully" : "Logged in successfully" ); - console.log(response) navigate("/dashboard", { replace: true }); } catch (error: any) { const message = diff --git a/web/src/features/auth/auth.api.ts b/web/src/features/auth/auth.api.ts index 4f4df9a..763b5ad 100644 --- a/web/src/features/auth/auth.api.ts +++ b/web/src/features/auth/auth.api.ts @@ -1,53 +1,46 @@ -import { apiClient } - from "../../api/client"; - -export interface AuthResponse { - success: boolean; - - data: { - user: { - id: string; - email: string; - }; - - accessToken: string; - }; -} - -export interface LoginPayload { - email: string; - password: string; -} - -export interface RegisterPayload { - email: string; - password: string; +import type { AuthPayload, AuthResponse } from "@/features/auth/auth"; +import { apiClient } from "../../api/client"; +import type { AppDispatch } from "@/store"; +import { bootstrapAuth, authStorage } from "@/utils/authHelper"; +import { setCredentials, finishBootstrap, logout } from "./auth.slice"; + +export const loginUser = async (payload: AuthPayload) => { + const response = await apiClient.post("/auth/login", payload); + const { user, accessToken } = response.data.data; + authStorage.setToken(accessToken); + return { user, accessToken }; +}; + +export const registerUser = async (payload: AuthPayload) => { + const response = await apiClient.post("/auth/register", payload); + const { user, accessToken } = response.data.data; + authStorage.setToken(accessToken); + return { user, accessToken }; +}; + +export const bootstrapAuthFlow = () => async (dispatch: AppDispatch) => { + try { + const me = await bootstrapAuth(); + + if (me) { + const token = authStorage.getToken(); + if (token) { + dispatch(setCredentials({ user: me, accessToken: token })); + } else { + authStorage.clear(); + dispatch(logout()); + } + } else { + authStorage.clear(); + dispatch(logout()); + } + } finally { + dispatch(finishBootstrap()); + } +}; + +export const logoutUser = async(number:"current"|"all")=>{ + if(number==="current")return await apiClient.post("/auth/signout"); + else if(number==="all")return await apiClient.post("auth/signout-all"); + else return; } - -export const loginUser = - async ( - payload: LoginPayload - ) => { - - const response = - await apiClient.post( - "/auth/login", - payload - ); - console.log(response); - return response.data; - }; - -export const registerUser = - async ( - payload: RegisterPayload - ) => { - - const response = - await apiClient.post( - "/auth/register", - payload - ); - - return response.data; - }; \ No newline at end of file diff --git a/web/src/features/auth/auth.hooks.ts b/web/src/features/auth/auth.hooks.ts index 921436e..01e7d30 100644 --- a/web/src/features/auth/auth.hooks.ts +++ b/web/src/features/auth/auth.hooks.ts @@ -1,24 +1,36 @@ import { useMutation } from "@tanstack/react-query"; +import {loginUser,registerUser} from "./auth.api"; +import type { AuthPayload } from "@/features/auth/auth"; +import { authStorage } from "@/utils/authHelper"; +import { useAppDispatch } from "@/hooks/useAppDispatch"; +import { logout } from "./auth.slice"; +import { useNavigate } from "react-router-dom"; +import { logoutUser } from "./auth.api"; +import { useCallback } from "react"; -import { - loginUser, - registerUser, - type LoginPayload, - type RegisterPayload, -} from "./auth.api"; +export const useLogin = () => + useMutation({ + mutationFn: (payload: AuthPayload) => loginUser(payload), +}); -export const useLogin = () => { - return useMutation({ - mutationFn: ( - payload: LoginPayload - ) => loginUser(payload), - }); -}; +export const useRegister = () => + useMutation({ + mutationFn: (payload: AuthPayload) => registerUser(payload), +}); + +export const useLogout = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); -export const useRegister = () => { - return useMutation({ - mutationFn: ( - payload: RegisterPayload - ) => registerUser(payload), - }); -}; \ No newline at end of file + const handleLogout = useCallback( + async (scope: "current" | "all") => { + await logoutUser(scope); + authStorage.clear(); + dispatch(logout()); + navigate("/", { replace: true }); + }, + [dispatch, navigate] + ); + + return handleLogout; +}; diff --git a/web/src/features/auth/auth.selectors.ts b/web/src/features/auth/auth.selectors.ts deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/features/auth/auth.slice.ts b/web/src/features/auth/auth.slice.ts index 1578c96..e4687b7 100644 --- a/web/src/features/auth/auth.slice.ts +++ b/web/src/features/auth/auth.slice.ts @@ -1,101 +1,45 @@ -import { - createSlice, - type PayloadAction, -} from "@reduxjs/toolkit"; - -export interface User { - id: string; - email: string; -} - -interface AuthState { - user: User | null; - accessToken: string | null; - isAuthenticated: boolean; -} +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; +import type { AuthState, User } from "@/features/auth/auth"; +import { authStorage } from "@/utils/authHelper"; const getInitialState = (): AuthState => { - const storedUser = - localStorage.getItem("user"); - - const storedToken = - localStorage.getItem( - "accessToken" - ); - + const token = authStorage.getToken(); return { - user: storedUser - ? JSON.parse(storedUser) - : null, - - accessToken: - storedToken ?? null, - - isAuthenticated: - !!storedToken, + user: null, + accessToken: token ?? null, + isAuthenticated: !!token, + isBootstrapping: true, }; }; -const initialState = - getInitialState(); +const initialState: AuthState = getInitialState(); const authSlice = createSlice({ name: "auth", - initialState, - reducers: { - loginSuccess: ( + setCredentials( state, - action: PayloadAction<{ - user: User; - accessToken: string; - }> - ) => { - state.user = - action.payload.user; - - state.accessToken = - action.payload.accessToken; - - state.isAuthenticated = - true; - - localStorage.setItem( - "user", - JSON.stringify( - action.payload.user - ) - ); - - localStorage.setItem( - "accessToken", - action.payload.accessToken - ); + action: PayloadAction<{ user: User; accessToken: string }> + ) { + state.user = action.payload.user; + state.accessToken = action.payload.accessToken; + state.isAuthenticated = true; }, - - logout: (state) => { + setAccessToken(state, action: PayloadAction) { + state.accessToken = action.payload; + state.isAuthenticated = !!action.payload; + }, + logout(state) { state.user = null; - state.accessToken = null; - - state.isAuthenticated = - false; - - localStorage.removeItem( - "user" - ); - - localStorage.removeItem( - "accessToken" - ); + state.isAuthenticated = false; + }, + finishBootstrap(state) { + state.isBootstrapping = false; }, }, }); -export const { - loginSuccess, - logout, -} = authSlice.actions; - -export default authSlice.reducer; \ No newline at end of file +export const { setCredentials, setAccessToken, logout, finishBootstrap } = authSlice.actions; +export default authSlice.reducer; diff --git a/web/src/features/auth/auth.ts b/web/src/features/auth/auth.ts new file mode 100644 index 0000000..8958e64 --- /dev/null +++ b/web/src/features/auth/auth.ts @@ -0,0 +1,29 @@ +export interface User { + id: string; + email: string; +} +export interface AuthState { + user: User | null; + + accessToken: string | null; + + isAuthenticated: boolean; + + isBootstrapping: boolean; +} + +export interface AuthPayload{ + email: string; + password: string; +} + +export interface AuthResponse { + success: boolean; + data: { + user: { + id: string; + email: string; + }; + accessToken: string; + }; +} \ No newline at end of file diff --git a/web/src/features/auth/auth.types.ts b/web/src/features/auth/auth.types.ts deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/hooks/useAppDispatch.ts b/web/src/hooks/useAppDispatch.ts index 7d0e4b5..a209da6 100644 --- a/web/src/hooks/useAppDispatch.ts +++ b/web/src/hooks/useAppDispatch.ts @@ -1,9 +1,4 @@ -import { useDispatch } - from "react-redux"; +import { useDispatch } from "react-redux"; +import type {AppDispatch} from "@/store"; -import type { - AppDispatch, -} from "@/store"; - -export const useAppDispatch = - useDispatch.withTypes(); \ No newline at end of file +export const useAppDispatch = useDispatch.withTypes(); \ No newline at end of file diff --git a/web/src/main.tsx b/web/src/main.tsx index e4f9448..47a7a3f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,29 +1,12 @@ import React from "react"; -import ReactDOM - from "react-dom/client"; - +import ReactDOM from "react-dom/client"; import "./index.css"; - -import App - from "./App"; - -import { - Provider, -} from "react-redux"; - -import { store } - from "./store"; - -import { - QueryProvider, -} from "./providers/QueryProvider"; - -import { - ThemeProvider, -} from "./providers/ThemeProvider"; - -import { Toaster } - from "./components/ui/sonner"; +import App from "./App"; +import {Provider} from "react-redux"; +import { store } from "./store"; +import {QueryProvider} from "./providers/QueryProvider"; +import {ThemeProvider} from "./providers/ThemeProvider"; +import { Toaster }from "./components/ui/sonner"; ReactDOM.createRoot( document.getElementById( @@ -35,20 +18,12 @@ ReactDOM.createRoot( - - - - - - - - ); \ No newline at end of file diff --git a/web/src/pages/NotFound.tsx b/web/src/pages/NotFound.tsx new file mode 100644 index 0000000..347e4de --- /dev/null +++ b/web/src/pages/NotFound.tsx @@ -0,0 +1,13 @@ +import { Link } from "react-router-dom"; + +function NotFound() { + return ( +
+

404 - Page not found

+

The page you're looking for doesn't exist.

+ Click here to go to Home page +
+ ); +} + +export default NotFound; diff --git a/web/src/routes/AppRouter.tsx b/web/src/routes/AppRouter.tsx index ed259af..ee8beea 100644 --- a/web/src/routes/AppRouter.tsx +++ b/web/src/routes/AppRouter.tsx @@ -1,17 +1,12 @@ -import { - BrowserRouter, - Route, - Routes, -} from "react-router-dom"; - +import {BrowserRouter,Route,Routes} from "react-router-dom"; import HomePage from "@/pages/HomePage"; import LoginPage from "@/pages/LoginPage"; import RegisterPage from "@/pages/RegisterPage"; import DashboardPage from "@/pages/DashboardPage"; - import ProtectedRoute from "./ProtectedRoute"; - import AppShell from "../components/layout/AppShell"; +import NotFound from "@/pages/NotFound"; +import PublicOnlyRoute from "./PublicOnlyRoute"; const AppRouter = () => { return ( @@ -19,22 +14,11 @@ const AppRouter = () => { {/* Public */} - - } - /> - - } - /> - - } - /> - + }> + }/> + }/> + }/> + {/* Protected */} { /> + } /> diff --git a/web/src/routes/ProtectedRoute.tsx b/web/src/routes/ProtectedRoute.tsx index 57135f3..477025a 100644 --- a/web/src/routes/ProtectedRoute.tsx +++ b/web/src/routes/ProtectedRoute.tsx @@ -1,24 +1,23 @@ -import { - Navigate, - Outlet, -} from "react-router-dom"; +import { Navigate, Outlet } from "react-router-dom"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/store"; +import { SquareSpin } from "@/components/ui/square-spin"; const ProtectedRoute = () => { - const token = - localStorage.getItem( - "accessToken" - ); - - if (!token) { + const { isAuthenticated, isBootstrapping } = useSelector( + (state: RootState) => state.auth + ); + if (isBootstrapping) { return ( - +
+ +
); } - + if (!isAuthenticated) { + return ; + } return ; }; -export default ProtectedRoute; \ No newline at end of file +export default ProtectedRoute; diff --git a/web/src/routes/PublicOnlyRoute.tsx b/web/src/routes/PublicOnlyRoute.tsx new file mode 100644 index 0000000..7f68d8a --- /dev/null +++ b/web/src/routes/PublicOnlyRoute.tsx @@ -0,0 +1,22 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/store"; +import { SquareSpin } from "@/components/ui/square-spin"; + +function PublicOnlyRoute() { + const { isAuthenticated, isBootstrapping } = useSelector( + (state: RootState) => state.auth + ); + if (isBootstrapping) { + return ( +
+ +
+ ); + } + if (isAuthenticated) { + return ; + } + return ; +} +export default PublicOnlyRoute; \ No newline at end of file diff --git a/web/src/store/index.ts b/web/src/store/index.ts index 7d75c48..feff68d 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -1,21 +1,9 @@ -import { - configureStore, -} from "@reduxjs/toolkit"; +import {configureStore} from "@reduxjs/toolkit"; -import authReducer - from "../features/auth/auth.slice"; +import authReducer from "../features/auth/auth.slice"; -export const store = - configureStore({ - reducer: { - auth: authReducer, - }, - }); +export const store = configureStore({reducer: {auth: authReducer}}); -export type RootState = - ReturnType< - typeof store.getState - >; +export type RootState = ReturnType; -export type AppDispatch = - typeof store.dispatch; \ No newline at end of file +export type AppDispatch = typeof store.dispatch; \ No newline at end of file diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/types/axios.d.ts b/web/src/types/axios.d.ts new file mode 100644 index 0000000..f08dde0 --- /dev/null +++ b/web/src/types/axios.d.ts @@ -0,0 +1,7 @@ +import "axios"; + +declare module "axios" { + export interface AxiosRequestConfig { + _retry?: boolean; + } +} \ No newline at end of file diff --git a/web/src/types/common.types.ts b/web/src/types/common.types.ts deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/utils/authHelper.ts b/web/src/utils/authHelper.ts new file mode 100644 index 0000000..43fee11 --- /dev/null +++ b/web/src/utils/authHelper.ts @@ -0,0 +1,60 @@ +import axios, { AxiosError } from "axios"; + +export const authStorage = { + getToken() { + return localStorage.getItem("accessToken"); + }, + setToken(token: string) { + localStorage.setItem("accessToken", token); + }, + clear() { + localStorage.removeItem("accessToken"); + }, +}; + +const API_URL = import.meta.env.VITE_API_URL; + +export const refreshAccessToken = async () => { + const response = await axios.post( + `${API_URL}/auth/refresh`, + {}, + { withCredentials: true } + ); + + const accessToken = response.data.data?.accessToken; + if (!accessToken) throw new Error("No access token in refresh response"); + + authStorage.setToken(accessToken); + return accessToken; +}; + +const fetchMe = async (token: string) => { + const response = await axios.get(`${API_URL}/auth/me`, { + headers: { Authorization: `Bearer ${token}` }, + withCredentials: true, + }); + return response.data; +}; + +export const bootstrapAuth = async () => { + try { + let token = authStorage.getToken(); + + if (token) { + try { + const me = await fetchMe(token); + return me; + } catch (err) { + const e = err as AxiosError; + if (e.response?.status !== 401) { + throw err; + } + } + } + token = await refreshAccessToken(); + const me = await fetchMe(token as string); + return me; + } catch { + return null; + } +}; From b7a0785430a9bd8430668264487dffc36bc1a1f4 Mon Sep 17 00:00:00 2001 From: Kashish Singh Date: Thu, 25 Jun 2026 00:58:15 +0530 Subject: [PATCH 7/9] UI changes --- web/src/components/tasks/CreateTaskDialog.tsx | 2 +- web/src/components/tasks/DeleteTaskDialog.tsx | 5 +- web/src/components/tasks/TaskCard.tsx | 54 +++++-------------- web/src/components/tasks/TaskFilters.tsx | 6 +-- web/src/components/tasks/TaskForm.tsx | 2 +- web/src/components/tasks/TaskStats.tsx | 36 +++---------- web/src/components/ui/select.tsx | 2 +- web/src/pages/DashboardPage.tsx | 2 +- 8 files changed, 30 insertions(+), 79 deletions(-) diff --git a/web/src/components/tasks/CreateTaskDialog.tsx b/web/src/components/tasks/CreateTaskDialog.tsx index 9a8b444..baac1a7 100644 --- a/web/src/components/tasks/CreateTaskDialog.tsx +++ b/web/src/components/tasks/CreateTaskDialog.tsx @@ -44,7 +44,7 @@ const handleSubmit = useCallback( - diff --git a/web/src/components/tasks/DeleteTaskDialog.tsx b/web/src/components/tasks/DeleteTaskDialog.tsx index 13a7a5a..bbc3261 100644 --- a/web/src/components/tasks/DeleteTaskDialog.tsx +++ b/web/src/components/tasks/DeleteTaskDialog.tsx @@ -43,7 +43,7 @@ const DeleteTaskDialog = ({ asChild > @@ -66,7 +66,7 @@ const DeleteTaskDialog = ({ - + Cancel @@ -74,6 +74,7 @@ const DeleteTaskDialog = ({ onClick={ handleDelete } + className="bg-red-400 cursor-pointer hover:bg-red-500 px-2 py-1 rounded-lg" > Delete diff --git a/web/src/components/tasks/TaskCard.tsx b/web/src/components/tasks/TaskCard.tsx index 10f32c2..df651fb 100644 --- a/web/src/components/tasks/TaskCard.tsx +++ b/web/src/components/tasks/TaskCard.tsx @@ -1,61 +1,31 @@ -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "../ui/card"; - -import { - Badge -} from "../ui/badge"; - -import type { - Task, -} from "@/features/tasks/task.types"; - -import EditTaskDialog - from "./EditTaskDialog"; - -import DeleteTaskDialog - from "./DeleteTaskDialog"; - +import {Card,CardContent,CardHeader,CardTitle,} from "../ui/card"; +import {Badge} from "../ui/badge"; +import type {Task} from "@/features/tasks/task.types"; +import EditTaskDialog from "./EditTaskDialog"; +import DeleteTaskDialog from "./DeleteTaskDialog"; interface Props { task: Task; } - const priorityVariant = { - LOW: - "bg-green-100 text-green-700", - - MEDIUM: - "bg-amber-100 text-amber-700", - - HIGH: - "bg-red-100 text-red-700", + LOW: "bg-green-100 text-green-700", + MEDIUM: "bg-amber-100 text-amber-700", + HIGH: "bg-red-100 text-red-700", }; const toDoVariant = { - DONE: - "bg-green-100 text-green-700", + DONE:"bg-green-100 text-green-700", - IN_PROGRESS: - "bg-amber-100 text-amber-700", + IN_PROGRESS:"bg-amber-100 text-amber-700", - TODO: - "bg-red-100 text-red-700", + TODO:"bg-red-100 text-red-700", }; - const TaskCard = ({ task, }: Props) => { - return (
- -
-
{task.title} @@ -104,7 +74,7 @@ const TaskCard = ({

- Due: + Due: {task.dueDate ? new Date( diff --git a/web/src/components/tasks/TaskFilters.tsx b/web/src/components/tasks/TaskFilters.tsx index ef4761d..35449e0 100644 --- a/web/src/components/tasks/TaskFilters.tsx +++ b/web/src/components/tasks/TaskFilters.tsx @@ -49,7 +49,7 @@ const TaskFilters = () => { return (