From c4ccb2bd6ca95f9b966f9b5f98889025414ebae0 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Mon, 22 Dec 2025 11:53:38 +0530 Subject: [PATCH] feat: Implement a structured API service layer for authentication and user management, replacing the old API utility. --- package-lock.json | 116 +++++++++++++++++++++--- package.json | 1 + src/app/dashboard/page.tsx | 4 +- src/app/dashboard/users/page.tsx | 9 +- src/context/AuthContext.tsx | 55 +++++------- src/lib/api.ts | 147 ------------------------------- src/services/api.ts | 120 +++++++++++++++++++++++++ src/services/auth.service.ts | 115 ++++++++++++++++++++++++ src/services/index.ts | 21 +++++ src/services/users.service.ts | 79 +++++++++++++++++ 10 files changed, 469 insertions(+), 198 deletions(-) delete mode 100644 src/lib/api.ts create mode 100644 src/services/api.ts create mode 100644 src/services/auth.service.ts create mode 100644 src/services/index.ts create mode 100644 src/services/users.service.ts diff --git a/package-lock.json b/package-lock.json index 3badc13..65df1d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "admin", "version": "0.1.0", "dependencies": { + "axios": "^1.13.2", "next": "16.1.0", "react": "19.2.3", "react-dom": "19.2.3" @@ -2363,6 +2364,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2389,6 +2396,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2496,7 +2514,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2596,6 +2613,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2754,6 +2783,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2781,7 +2819,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -2893,7 +2930,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2903,7 +2939,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2941,7 +2976,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2954,7 +2988,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3587,6 +3620,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -3603,11 +3656,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3668,7 +3736,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3693,7 +3760,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -3781,7 +3847,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3853,7 +3918,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3866,7 +3930,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -3882,7 +3945,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4865,7 +4927,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4895,6 +4956,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5365,6 +5447,12 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index 0cbac27..93ac0b5 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "axios": "^1.13.2", "next": "16.1.0", "react": "19.2.3", "react-dom": "19.2.3" diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index c6d4b52..effe873 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { api, type User } from '@/lib/api'; +import { usersService, User } from '@/services'; import Link from 'next/link'; export default function DashboardPage() { @@ -21,7 +21,7 @@ export default function DashboardPage() { const fetchDashboardData = async () => { setIsLoading(true); try { - const response = await api.getUsers(1, 100); + const response = await usersService.getUsers({ page: 1, limit: 100 }); const users = response.users || []; setStats({ totalUsers: response.total || 0, diff --git a/src/app/dashboard/users/page.tsx b/src/app/dashboard/users/page.tsx index ab6bf27..9a5d6b5 100644 --- a/src/app/dashboard/users/page.tsx +++ b/src/app/dashboard/users/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { api, type User } from '@/lib/api'; +import { usersService, User, getErrorMessage } from '@/services'; export default function UsersPage() { const router = useRouter(); @@ -21,12 +21,13 @@ export default function UsersPage() { setIsLoading(true); setError(''); try { - const response = await api.getUsers(currentPage, limit); + const response = await usersService.getUsers({ page: currentPage, limit }); setUsers(response.users || []); setTotalUsers(response.total || 0); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch users'); - if (err instanceof Error && err.message.includes('Unauthorized')) { + const errorMessage = getErrorMessage(err); + setError(errorMessage); + if (errorMessage.includes('Unauthorized')) { router.push('/login'); } } finally { diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index aa66831..01733a4 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -2,53 +2,38 @@ import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { api, type LoginResponse } from '@/lib/api'; - -interface User { - id: string; - email: string; - role: string; - firstName: string; - lastName: string; - avatar: string | null; -} +import { authService, AuthUser, getErrorMessage } from '@/services'; interface AuthContextType { - user: User | null; + user: AuthUser | null; isLoading: boolean; isAuthenticated: boolean; login: (email: string, password: string) => Promise; logout: () => Promise; + error: string | null; } const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: React.ReactNode }) { - const [user, setUser] = useState(null); + const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); const router = useRouter(); const checkAuth = useCallback(() => { try { - const token = localStorage.getItem('accessToken'); - const storedUser = localStorage.getItem('user'); + const storedUser = authService.getStoredUser(); - if (token && storedUser) { - const parsedUser = JSON.parse(storedUser); - if (parsedUser.role === 'ADMIN') { - setUser(parsedUser); - } else { - // Not admin, clear storage - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); - } + if (storedUser && storedUser.role === 'ADMIN') { + setUser(storedUser); + } else if (storedUser) { + // Not admin, clear storage + authService.clearAuth(); } } catch { // Invalid stored data - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); + authService.clearAuth(); } finally { setIsLoading(false); } @@ -59,14 +44,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, [checkAuth]); const login = async (email: string, password: string) => { - const response: LoginResponse = await api.login(email, password); - setUser(response.user); - router.push('/dashboard'); + setError(null); + try { + const response = await authService.login({ email, password }); + setUser(response.user); + router.push('/dashboard'); + } catch (err) { + const errorMessage = getErrorMessage(err); + setError(errorMessage); + throw new Error(errorMessage); + } }; const logout = async () => { try { - await api.logout(); + await authService.logout(); } finally { setUser(null); router.push('/login'); @@ -81,6 +73,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { isAuthenticated: !!user, login, logout, + error, }} > {children} diff --git a/src/lib/api.ts b/src/lib/api.ts deleted file mode 100644 index 623a84e..0000000 --- a/src/lib/api.ts +++ /dev/null @@ -1,147 +0,0 @@ -const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'; - -interface ApiResponse { - success: boolean; - data: T; - message?: string; -} - -interface LoginResponse { - user: { - id: string; - email: string; - role: string; - firstName: string; - lastName: string; - avatar: string | null; - }; - accessToken: string; - refreshToken: string; -} - -interface User { - id: string; - email: string; - role: string; - status: string; - emailVerified: boolean; - authProvider: string; - createdAt: string; - lastLoginAt: string | null; - profile: { - firstName: string; - lastName: string; - avatar: string | null; - phone: string | null; - city: string | null; - state: string | null; - country: string | null; - } | null; -} - -class ApiClient { - private baseUrl: string; - - constructor() { - this.baseUrl = API_URL; - } - - private getToken(): string | null { - if (typeof window === 'undefined') return null; - return localStorage.getItem('accessToken'); - } - - private async request( - endpoint: string, - options: RequestInit = {} - ): Promise> { - const token = this.getToken(); - - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...(token && { Authorization: `Bearer ${token}` }), - ...options.headers, - }; - - const response = await fetch(`${this.baseUrl}${endpoint}`, { - ...options, - headers, - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || 'An error occurred'); - } - - return data; - } - - async login(email: string, password: string): Promise { - const response = await this.request('/auth/login', { - method: 'POST', - body: JSON.stringify({ email, password }), - }); - - // Check if user is ADMIN - if (response.data.user.role !== 'ADMIN') { - throw new Error('Access denied. Admin privileges required.'); - } - - // Store tokens - localStorage.setItem('accessToken', response.data.accessToken); - localStorage.setItem('refreshToken', response.data.refreshToken); - localStorage.setItem('user', JSON.stringify(response.data.user)); - - return response.data; - } - - async logout(): Promise { - try { - await this.request('/auth/logout', { method: 'POST' }); - } finally { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); - } - } - - async getCurrentUser(): Promise { - const response = await this.request('/auth/me'); - return response.data; - } - - async getUsers(page = 1, limit = 10): Promise<{ users: User[]; total: number; page: number; limit: number }> { - const response = await this.request<{ users: User[]; total: number; page: number; limit: number }>( - `/users?page=${page}&limit=${limit}` - ); - return response.data; - } - - async refreshToken(): Promise<{ accessToken: string; refreshToken: string }> { - const refreshToken = localStorage.getItem('refreshToken'); - if (!refreshToken) { - throw new Error('No refresh token available'); - } - - const response = await fetch(`${this.baseUrl}/auth/refresh`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refreshToken }), - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || 'Failed to refresh token'); - } - - localStorage.setItem('accessToken', data.data.accessToken); - localStorage.setItem('refreshToken', data.data.refreshToken); - - return data.data; - } -} - -export const api = new ApiClient(); -export type { User, LoginResponse }; diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..6ce1c1b --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,120 @@ +import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios'; + +// API Response types +export interface ApiResponse { + success: boolean; + data: T; + statusCode?: number; + timestamp?: string; + message?: string; +} + +export interface ApiErrorResponse { + success: false; + statusCode: number; + timestamp: string; + path: string; + method: string; + error: string; + message: string | string[]; + errors?: Array<{ field: string; errors: string[] }>; +} + +// Create axios instance +const api: AxiosInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1', + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor +api.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + // Add auth token if available + if (typeof window !== 'undefined') { + const token = localStorage.getItem('accessToken'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + } + return config; + }, + (error: AxiosError) => { + return Promise.reject(error); + } +); + +// Response interceptor +api.interceptors.response.use( + (response) => { + return response; + }, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + // Handle 401 Unauthorized - try to refresh token + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = localStorage.getItem('refreshToken'); + if (refreshToken) { + const response = await axios.post( + `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}/auth/refresh`, + { refreshToken } + ); + + const { accessToken, refreshToken: newRefreshToken } = response.data.data; + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', newRefreshToken); + + // Retry original request with new token + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + } + return api(originalRequest); + } + } catch (refreshError) { + // Refresh failed, clear storage and redirect to login + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + } + } + + return Promise.reject(error); + } +); + +// Helper to extract error message +export function getErrorMessage(error: unknown): string { + if (error instanceof AxiosError) { + const apiError = error.response?.data as ApiErrorResponse; + if (apiError?.message) { + return Array.isArray(apiError.message) + ? apiError.message.join(', ') + : apiError.message; + } + } + if (error instanceof Error) { + return error.message; + } + return 'An unexpected error occurred'; +} + +// Helper to extract field errors +export function getFieldErrors(error: unknown): Array<{ field: string; errors: string[] }> { + if (error instanceof AxiosError) { + const apiError = error.response?.data as ApiErrorResponse; + return apiError?.errors || []; + } + return []; +} + +export default api; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 0000000..06d48c9 --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,115 @@ +import api, { ApiResponse } from './api'; + +// Types +export interface LoginRequest { + email: string; + password: string; +} + +export interface AuthUser { + id: string; + email: string; + role: string; + firstName: string; + lastName: string; + avatar: string | null; +} + +export interface AuthResponse { + user: AuthUser; + accessToken: string; + refreshToken: string; +} + +export interface MessageResponse { + message: string; +} + +// Auth Service +class AuthService { + private basePath = '/auth'; + + async login(data: LoginRequest): Promise { + const response = await api.post>( + `${this.basePath}/login`, + data + ); + + const authData = response.data.data; + + // Check if user is ADMIN + if (authData.user.role !== 'ADMIN') { + throw new Error('Access denied. Admin privileges required.'); + } + + // Store tokens and user + localStorage.setItem('accessToken', authData.accessToken); + localStorage.setItem('refreshToken', authData.refreshToken); + localStorage.setItem('user', JSON.stringify(authData.user)); + + return authData; + } + + async logout(): Promise { + try { + await api.post>(`${this.basePath}/logout`); + } finally { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + } + } + + async getCurrentUser(): Promise { + const response = await api.get>(`${this.basePath}/me`); + return response.data.data; + } + + async refreshToken(): Promise<{ accessToken: string; refreshToken: string }> { + const refreshToken = localStorage.getItem('refreshToken'); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + const response = await api.post>( + `${this.basePath}/refresh`, + { refreshToken } + ); + + const tokens = response.data.data; + localStorage.setItem('accessToken', tokens.accessToken); + localStorage.setItem('refreshToken', tokens.refreshToken); + + return tokens; + } + + // Get stored user from localStorage + getStoredUser(): AuthUser | null { + if (typeof window === 'undefined') return null; + + const storedUser = localStorage.getItem('user'); + if (storedUser) { + try { + return JSON.parse(storedUser); + } catch { + return null; + } + } + return null; + } + + // Check if user is authenticated + isAuthenticated(): boolean { + if (typeof window === 'undefined') return false; + return !!localStorage.getItem('accessToken'); + } + + // Clear auth data + clearAuth(): void { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + } +} + +export const authService = new AuthService(); diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..d26a3c0 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,21 @@ +// API Client +export { default as api } from './api'; +export type { ApiResponse, ApiErrorResponse } from './api'; +export { getErrorMessage, getFieldErrors } from './api'; + +// Auth Service +export { authService } from './auth.service'; +export type { + LoginRequest, + AuthUser, + AuthResponse, + MessageResponse, +} from './auth.service'; + +// Users Service +export { usersService } from './users.service'; +export type { + User, + UsersListResponse, + UserFilters, +} from './users.service'; diff --git a/src/services/users.service.ts b/src/services/users.service.ts new file mode 100644 index 0000000..13188ad --- /dev/null +++ b/src/services/users.service.ts @@ -0,0 +1,79 @@ +import api, { ApiResponse } from './api'; + +// Types +export interface User { + id: string; + email: string; + role: string; + status: string; + emailVerified: boolean; + authProvider: string; + createdAt: string; + lastLoginAt: string | null; + profile: { + firstName: string; + lastName: string; + avatar: string | null; + phone: string | null; + city: string | null; + state: string | null; + country: string | null; + } | null; +} + +export interface UsersListResponse { + users: User[]; + total: number; + page: number; + limit: number; +} + +export interface UserFilters { + page?: number; + limit?: number; + role?: string; + status?: string; + search?: string; +} + +// Users Service +class UsersService { + private basePath = '/users'; + + async getUsers(filters: UserFilters = {}): Promise { + const params = new URLSearchParams(); + + if (filters.page) params.append('page', filters.page.toString()); + if (filters.limit) params.append('limit', filters.limit.toString()); + if (filters.role) params.append('role', filters.role); + if (filters.status) params.append('status', filters.status); + if (filters.search) params.append('search', filters.search); + + const queryString = params.toString(); + const url = queryString ? `${this.basePath}?${queryString}` : this.basePath; + + const response = await api.get>(url); + return response.data.data; + } + + async getUserById(id: string): Promise { + const response = await api.get>(`${this.basePath}/${id}`); + return response.data.data; + } + + async updateUser(id: string, data: Partial): Promise { + const response = await api.patch>(`${this.basePath}/${id}`, data); + return response.data.data; + } + + async deleteUser(id: string): Promise { + await api.delete(`${this.basePath}/${id}`); + } + + async updateUserStatus(id: string, status: string): Promise { + const response = await api.patch>(`${this.basePath}/${id}/status`, { status }); + return response.data.data; + } +} + +export const usersService = new UsersService();