feat: Implement admin dashboard, login, user management, authentication, and API utilities with port configuration.

This commit is contained in:
pradeepkumar
2025-12-20 19:22:20 +05:30
parent 2e99d8617b
commit aa97e36cfd
10 changed files with 1192 additions and 64 deletions

View File

@@ -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<HTMLDivElement>(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 (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="min-h-screen bg-gray-50">
{/* Sidebar */}
<Sidebar />
{/* Main Content */}
<div className="ml-64">
{/* Top Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
<div className="px-8 py-3">
<div className="flex justify-end items-center">
{/* User Dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="flex items-center gap-3 py-2 px-3 rounded-xl hover:bg-gray-50 transition-all duration-200"
>
<div className="w-9 h-9 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-sm">
<span className="text-white font-semibold text-sm">
{user?.firstName?.[0] || user?.email?.[0]?.toUpperCase() || 'A'}
</span>
</div>
<div className="text-left hidden sm:block">
<p className="text-sm font-semibold text-gray-800">
{user?.firstName || 'Admin'} {user?.lastName || ''}
</p>
<p className="text-xs text-gray-500">{user?.email}</p>
</div>
<svg
className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown Menu */}
{isDropdownOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden z-50">
<div className="px-4 py-4 bg-gradient-to-br from-gray-50 to-white border-b border-gray-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-sm">
<span className="text-white font-semibold">
{user?.firstName?.[0] || user?.email?.[0]?.toUpperCase() || 'A'}
</span>
</div>
<div>
<p className="text-sm font-semibold text-gray-800">
{user?.firstName || 'Admin'} {user?.lastName || ''}
</p>
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
</div>
</div>
</div>
<div className="p-2">
<button
onClick={handleLogout}
className="w-full px-3 py-2.5 text-left text-sm text-red-600 hover:bg-red-50 rounded-lg flex items-center gap-3 transition-colors duration-150"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign out
</button>
</div>
</div>
)}
</div>
</div>
</div>
</header>
{/* Page Content */}
<main className="p-8">{children}</main>
</div>
</div>
);
}

269
src/app/dashboard/page.tsx Normal file
View File

@@ -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<User[]>([]);
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 (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div>
{/* Page Title */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-500">Welcome to Re-Quest Admin Panel</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-3 bg-blue-100 rounded-lg">
<svg
className="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Total Users</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalUsers}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-3 bg-green-100 rounded-lg">
<svg
className="w-6 h-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Active</p>
<p className="text-2xl font-bold text-gray-900">{stats.activeUsers}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-3 bg-purple-100 rounded-lg">
<svg
className="w-6 h-6 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Agents</p>
<p className="text-2xl font-bold text-gray-900">{stats.agents}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-3 bg-yellow-100 rounded-lg">
<svg
className="w-6 h-6 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Pending</p>
<p className="text-2xl font-bold text-gray-900">{stats.pendingUsers}</p>
</div>
</div>
</div>
</div>
{/* Recent Users */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-gray-900">Recent Users</h2>
<Link
href="/dashboard/users"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
View All
</Link>
</div>
</div>
<div className="overflow-x-auto">
{recentUsers.length === 0 ? (
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<p className="mt-2 text-gray-500">No users found</p>
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Joined
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{recentUsers.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
{user.profile?.avatar ? (
<img
className="h-10 w-10 rounded-full object-cover"
src={user.profile.avatar}
alt=""
/>
) : (
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 font-medium text-sm">
{user.profile?.firstName?.[0] || user.email[0].toUpperCase()}
</span>
</div>
)}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{user.profile?.firstName} {user.profile?.lastName}
</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.role === 'ADMIN'
? 'bg-red-100 text-red-800'
: user.role === 'AGENT'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.status === 'ACTIVE'
? 'bg-green-100 text-green-800'
: user.status === 'PENDING_VERIFICATION'
? 'bg-yellow-100 text-yellow-800'
: user.status === 'SUSPENDED'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{user.status === 'PENDING_VERIFICATION' ? 'PENDING' : user.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}

View File

@@ -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<User[]>([]);
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 (
<div>
{/* Page Title */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
<p className="text-gray-500">Manage all registered users</p>
</div>
{/* Users Table */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-gray-900">All Users</h2>
<button
onClick={fetchUsers}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors flex items-center"
>
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Refresh
</button>
</div>
</div>
{error && (
<div className="px-6 py-4 bg-red-50 border-b border-red-200">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
<div className="overflow-x-auto">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : users.length === 0 ? (
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<p className="mt-2 text-gray-500">No users found</p>
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Provider
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Verified
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Joined
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
{user.profile?.avatar ? (
<img
className="h-10 w-10 rounded-full object-cover"
src={user.profile.avatar}
alt=""
/>
) : (
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 font-medium text-sm">
{user.profile?.firstName?.[0] || user.email[0].toUpperCase()}
</span>
</div>
)}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{user.profile?.firstName} {user.profile?.lastName}
</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.role === 'ADMIN'
? 'bg-red-100 text-red-800'
: user.role === 'AGENT'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.status === 'ACTIVE'
? 'bg-green-100 text-green-800'
: user.status === 'PENDING_VERIFICATION'
? 'bg-yellow-100 text-yellow-800'
: user.status === 'SUSPENDED'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{user.status === 'PENDING_VERIFICATION' ? 'PENDING' : user.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.authProvider === 'LOCAL' ? 'Email' : user.authProvider}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{user.emailVerified ? (
<svg
className="w-5 h-5 text-green-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="w-5 h-5 text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleDateString()
: 'Never'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<p className="text-sm text-gray-500">
Showing {(currentPage - 1) * limit + 1} to{' '}
{Math.min(currentPage * limit, totalUsers)} of {totalUsers} users
</p>
<div className="flex space-x-2">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -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({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);

171
src/app/login/page.tsx Normal file
View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="max-w-md w-full mx-4">
<div className="bg-white rounded-lg shadow-lg p-8">
{/* Header */}
<div className="text-center mb-8">
<div className="mx-auto w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center mb-4">
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900">Re-Quest Admin</h1>
<p className="text-gray-500 mt-2">Re-Quest Platform Administration</p>
</div>
{/* Error Alert */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<svg
className="w-5 h-5 text-red-500 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<span className="text-red-700 text-sm">{error}</span>
</div>
</div>
)}
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-2"
>
Email Address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-2"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center"
>
{isSubmitting ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Signing in...
</>
) : (
'Sign In'
)}
</button>
</form>
{/* Footer */}
<div className="mt-6 text-center">
<p className="text-xs text-gray-500">
Only administrators can access this portal
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const menuItems = [
{
name: 'Dashboard',
href: '/dashboard',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
),
},
{
name: 'Users',
href: '/dashboard/users',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
),
},
];
export default function Sidebar() {
const pathname = usePathname();
return (
<aside className="w-64 bg-gray-900 min-h-screen fixed left-0 top-0">
{/* Logo */}
<div className="p-6 border-b border-gray-800">
<div className="flex items-center">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center mr-3">
<svg
className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
</div>
<div>
<h1 className="text-lg font-bold text-white">Re-Quest</h1>
<p className="text-xs text-gray-400">Admin Panel</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="p-4">
<ul className="space-y-2">
{menuItems.map((item) => {
const isActive = pathname === item.href ||
(item.href !== '/dashboard' && pathname.startsWith(item.href));
return (
<li key={item.name}>
<Link
href={item.href}
className={`flex items-center px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`}
>
{item.icon}
<span className="ml-3 font-medium">{item.name}</span>
</Link>
</li>
);
})}
</ul>
</nav>
</aside>
);
}

View File

@@ -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<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(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 (
<AuthContext.Provider
value={{
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

147
src/lib/api.ts Normal file
View File

@@ -0,0 +1,147 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
interface ApiResponse<T> {
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<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
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<LoginResponse> {
const response = await this.request<LoginResponse>('/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<void> {
try {
await this.request('/auth/logout', { method: 'POST' });
} finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
}
}
async getCurrentUser(): Promise<User> {
const response = await this.request<User>('/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 };