From aa97e36cfd5047b18811756b73673b605ee79f3f Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Sat, 20 Dec 2025 19:22:20 +0530 Subject: [PATCH] feat: Implement admin dashboard, login, user management, authentication, and API utilities with port configuration. --- package.json | 4 +- src/app/dashboard/layout.tsx | 130 +++++++++++++++ src/app/dashboard/page.tsx | 269 +++++++++++++++++++++++++++++++ src/app/dashboard/users/page.tsx | 258 +++++++++++++++++++++++++++++ src/app/layout.tsx | 7 +- src/app/login/page.tsx | 171 ++++++++++++++++++++ src/app/page.tsx | 79 +++------ src/components/Sidebar.tsx | 94 +++++++++++ src/context/AuthContext.tsx | 97 +++++++++++ src/lib/api.ts | 147 +++++++++++++++++ 10 files changed, 1192 insertions(+), 64 deletions(-) create mode 100644 src/app/dashboard/layout.tsx create mode 100644 src/app/dashboard/page.tsx create mode 100644 src/app/dashboard/users/page.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/components/Sidebar.tsx create mode 100644 src/context/AuthContext.tsx create mode 100644 src/lib/api.ts diff --git a/package.json b/package.json index 61b9b7c..0cbac27 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3002", "build": "next build", - "start": "next start", + "start": "next start -p 3002", "lint": "eslint" }, "dependencies": { diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..a35a1ed --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/context/AuthContext'; +import Sidebar from '@/components/Sidebar'; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { user, isLoading, isAuthenticated, logout } = useAuth(); + const router = useRouter(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push('/login'); + } + }, [isAuthenticated, isLoading, router]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleLogout = async () => { + setIsDropdownOpen(false); + await logout(); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {/* Top Header */} +
+
+
+ {/* User Dropdown */} +
+ + + {/* Dropdown Menu */} + {isDropdownOpen && ( +
+
+
+
+ + {user?.firstName?.[0] || user?.email?.[0]?.toUpperCase() || 'A'} + +
+
+

+ {user?.firstName || 'Admin'} {user?.lastName || ''} +

+

{user?.email}

+
+
+
+
+ +
+
+ )} +
+
+
+
+ + {/* Page Content */} +
{children}
+
+
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..c6d4b52 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { api, type User } from '@/lib/api'; +import Link from 'next/link'; + +export default function DashboardPage() { + const [stats, setStats] = useState({ + totalUsers: 0, + activeUsers: 0, + agents: 0, + pendingUsers: 0, + }); + const [isLoading, setIsLoading] = useState(true); + const [recentUsers, setRecentUsers] = useState([]); + + useEffect(() => { + fetchDashboardData(); + }, []); + + const fetchDashboardData = async () => { + setIsLoading(true); + try { + const response = await api.getUsers(1, 100); + const users = response.users || []; + setStats({ + totalUsers: response.total || 0, + activeUsers: users.filter((u) => u.status === 'ACTIVE').length, + agents: users.filter((u) => u.role === 'AGENT').length, + pendingUsers: users.filter((u) => u.status === 'PENDING_VERIFICATION').length, + }); + setRecentUsers(users.slice(0, 5)); + } catch (err) { + console.error('Failed to fetch dashboard data:', err); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Page Title */} +
+

Dashboard

+

Welcome to Re-Quest Admin Panel

+
+ + {/* Stats Cards */} +
+
+
+
+ + + +
+
+

Total Users

+

{stats.totalUsers}

+
+
+
+
+
+
+ + + +
+
+

Active

+

{stats.activeUsers}

+
+
+
+
+
+
+ + + +
+
+

Agents

+

{stats.agents}

+
+
+
+
+
+
+ + + +
+
+

Pending

+

{stats.pendingUsers}

+
+
+
+
+ + {/* Recent Users */} +
+
+
+

Recent Users

+ + View All → + +
+
+
+ {recentUsers.length === 0 ? ( +
+ + + +

No users found

+
+ ) : ( + + + + + + + + + + + {recentUsers.map((user) => ( + + + + + + + ))} + +
+ User + + Role + + Status + + Joined +
+
+
+ {user.profile?.avatar ? ( + + ) : ( +
+ + {user.profile?.firstName?.[0] || user.email[0].toUpperCase()} + +
+ )} +
+
+
+ {user.profile?.firstName} {user.profile?.lastName} +
+
{user.email}
+
+
+
+ + {user.role} + + + + {user.status === 'PENDING_VERIFICATION' ? 'PENDING' : user.status} + + + {new Date(user.createdAt).toLocaleDateString()} +
+ )} +
+
+
+ ); +} diff --git a/src/app/dashboard/users/page.tsx b/src/app/dashboard/users/page.tsx new file mode 100644 index 0000000..ab6bf27 --- /dev/null +++ b/src/app/dashboard/users/page.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { api, type User } from '@/lib/api'; + +export default function UsersPage() { + const router = useRouter(); + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [totalUsers, setTotalUsers] = useState(0); + const limit = 10; + + useEffect(() => { + fetchUsers(); + }, [currentPage]); + + const fetchUsers = async () => { + setIsLoading(true); + setError(''); + try { + const response = await api.getUsers(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')) { + router.push('/login'); + } + } finally { + setIsLoading(false); + } + }; + + const totalPages = Math.ceil(totalUsers / limit); + + return ( +
+ {/* Page Title */} +
+

Users

+

Manage all registered users

+
+ + {/* Users Table */} +
+
+
+

All Users

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

{error}

+
+ )} + +
+ {isLoading ? ( +
+
+
+ ) : users.length === 0 ? ( +
+ + + +

No users found

+
+ ) : ( + + + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + + ))} + +
+ User + + Role + + Status + + Provider + + Verified + + Joined + + Last Login +
+
+
+ {user.profile?.avatar ? ( + + ) : ( +
+ + {user.profile?.firstName?.[0] || user.email[0].toUpperCase()} + +
+ )} +
+
+
+ {user.profile?.firstName} {user.profile?.lastName} +
+
{user.email}
+
+
+
+ + {user.role} + + + + {user.status === 'PENDING_VERIFICATION' ? 'PENDING' : user.status} + + + {user.authProvider === 'LOCAL' ? 'Email' : user.authProvider} + + {user.emailVerified ? ( + + + + ) : ( + + + + )} + + {new Date(user.createdAt).toLocaleDateString()} + + {user.lastLoginAt + ? new Date(user.lastLoginAt).toLocaleDateString() + : 'Never'} +
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Showing {(currentPage - 1) * limit + 1} to{' '} + {Math.min(currentPage * limit, totalUsers)} of {totalUsers} users +

+
+ + +
+
+ )} +
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..9512fa7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { AuthProvider } from "@/context/AuthContext"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Re-Quest Admin", + description: "Admin dashboard for Re-Quest Platform", }; export default function RootLayout({ @@ -27,7 +28,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..972f2b6 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useState } from 'react'; +import { useAuth } from '@/context/AuthContext'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; + +export default function LoginPage() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const { login, isAuthenticated, isLoading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && isAuthenticated) { + router.push('/dashboard'); + } + }, [isAuthenticated, isLoading, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsSubmitting(true); + + try { + await login(email, password); + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + } finally { + setIsSubmitting(false); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+ {/* Header */} +
+
+ + + +
+

Re-Quest Admin

+

Re-Quest Platform Administration

+
+ + {/* Error Alert */} + {error && ( +
+
+ + + + {error} +
+
+ )} + + {/* Login Form */} +
+
+ + setEmail(e.target.value)} + required + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-gray-900 placeholder:text-gray-400 bg-white" + placeholder="admin@realestate.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-gray-900 placeholder:text-gray-400 bg-white" + placeholder="Enter your password" + /> +
+ + +
+ + {/* Footer */} +
+

+ Only administrators can access this portal +

+
+
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..7a75746 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,26 @@ -import Image from "next/image"; +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/context/AuthContext'; export default function Home() { + const { isAuthenticated, isLoading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading) { + if (isAuthenticated) { + router.push('/dashboard'); + } else { + router.push('/login'); + } + } + }, [isAuthenticated, isLoading, router]); + return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
+
+
); } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..8e56a62 --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,94 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +const menuItems = [ + { + name: 'Dashboard', + href: '/dashboard', + icon: ( + + + + ), + }, + { + name: 'Users', + href: '/dashboard/users', + icon: ( + + + + ), + }, +]; + +export default function Sidebar() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..aa66831 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,97 @@ +'use client'; + +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; +} + +interface AuthContextType { + user: User | null; + isLoading: boolean; + isAuthenticated: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const router = useRouter(); + + const checkAuth = useCallback(() => { + try { + const token = localStorage.getItem('accessToken'); + const storedUser = localStorage.getItem('user'); + + 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'); + } + } + } catch { + // Invalid stored data + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + const login = async (email: string, password: string) => { + const response: LoginResponse = await api.login(email, password); + setUser(response.user); + router.push('/dashboard'); + }; + + const logout = async () => { + try { + await api.logout(); + } finally { + setUser(null); + router.push('/login'); + } + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..623a84e --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,147 @@ +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 };