diff --git a/backend/src/admin/admin.controller.spec.ts b/backend/src/admin/admin.controller.spec.ts new file mode 100644 index 00000000..146cbff7 --- /dev/null +++ b/backend/src/admin/admin.controller.spec.ts @@ -0,0 +1,230 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminController } from './admin.controller'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User, UserRole } from '../auth/entities/user.entity'; +import { Donation } from '../donations/entities/donation.entity'; +import { NotFoundException } from '@nestjs/common'; + +describe('AdminController', () => { + let controller: AdminController; + + const mockUserRepository = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + }; + + const mockDonationRepository = { + find: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminController], + providers: [ + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + { + provide: getRepositoryToken(Donation), + useValue: mockDonationRepository, + }, + ], + }).compile(); + + controller = module.get(AdminController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getPendingNGOs', () => { + it('should return list of unverified NGOs', async () => { + const mockNGOs = [ + { + id: '1', + name: 'Food Bank A', + email: 'foodbank@example.com', + role: UserRole.NGO, + isVerified: false, + organizationName: 'Food Bank A', + phone: '1234567890', + address: '123 Main St', + createdAt: new Date(), + }, + ]; + + mockUserRepository.find.mockResolvedValue(mockNGOs); + + const result = await controller.getPendingNgos(); + + expect(mockUserRepository.find).toHaveBeenCalledWith({ + where: { + role: UserRole.NGO, + isVerified: false, + }, + select: ['id', 'name', 'email', 'organizationName', 'phone', 'address', 'createdAt'], + }); + expect(result).toEqual(mockNGOs); + }); + }); + + describe('verifyNgo', () => { + it('should verify an NGO and update isVerified to true', async () => { + const ngoId = '123'; + const mockNGO = { + id: ngoId, + name: 'Food Bank A', + organizationName: 'Food Bank A', + isVerified: false, + }; + + mockUserRepository.findOne.mockResolvedValue(mockNGO); + mockUserRepository.save.mockResolvedValue({ ...mockNGO, isVerified: true }); + + const result = await controller.verifyNgo(ngoId); + + expect(mockUserRepository.findOne).toHaveBeenCalledWith({ where: { id: ngoId } }); + expect(mockUserRepository.save).toHaveBeenCalledWith({ ...mockNGO, isVerified: true }); + expect(result.message).toContain('verified'); + }); + + it('should throw NotFoundException if NGO does not exist', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(controller.verifyNgo('invalid-id')).rejects.toThrow(NotFoundException); + }); + }); + + describe('getAllUsers', () => { + it('should return all users with specific fields', async () => { + const mockUsers = [ + { + id: '1', + name: 'John Donor', + email: 'john@example.com', + role: UserRole.DONOR, + isVerified: true, + isActive: true, + createdAt: new Date(), + }, + { + id: '2', + name: 'NGO Helper', + email: 'ngo@example.com', + role: UserRole.NGO, + organizationName: 'Helper Org', + isVerified: false, + isActive: true, + createdAt: new Date(), + }, + ]; + + mockUserRepository.find.mockResolvedValue(mockUsers); + + const result = await controller.getAllUsers(); + + expect(mockUserRepository.find).toHaveBeenCalledWith({ + select: ['id', 'name', 'email', 'role', 'organizationName', 'isVerified', 'isActive', 'createdAt'], + order: { createdAt: 'DESC' }, + }); + expect(result).toEqual(mockUsers); + }); + }); + + describe('toggleUserStatus', () => { + it('should suspend an active user', async () => { + const userId = '123'; + const mockUser = { + id: userId, + name: 'John Doe', + role: UserRole.DONOR, + isActive: true, + }; + + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockUserRepository.save.mockResolvedValue({ ...mockUser, isActive: false }); + + const result = await controller.toggleUserStatus(userId); + + expect(mockUserRepository.save).toHaveBeenCalledWith({ ...mockUser, isActive: false }); + expect(result.message).toContain('suspended'); + expect(result.isActive).toBe(false); + }); + + it('should restore a suspended user', async () => { + const userId = '123'; + const mockUser = { + id: userId, + name: 'John Doe', + role: UserRole.DONOR, + isActive: false, + }; + + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockUserRepository.save.mockResolvedValue({ ...mockUser, isActive: true }); + + const result = await controller.toggleUserStatus(userId); + + expect(mockUserRepository.save).toHaveBeenCalledWith({ ...mockUser, isActive: true }); + expect(result.message).toContain('unbanned'); + expect(result.isActive).toBe(true); + }); + + it('should prevent suspending an admin account', async () => { + const adminUser = { + id: 'admin-123', + name: 'System Admin', + role: UserRole.ADMIN, + isActive: true, + }; + + mockUserRepository.findOne.mockResolvedValue(adminUser); + + await expect(controller.toggleUserStatus('admin-123')).rejects.toThrow( + 'Cannot suspend an administrator account', + ); + }); + + it('should throw NotFoundException if user does not exist', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(controller.toggleUserStatus('invalid-id')).rejects.toThrow(NotFoundException); + }); + }); + + describe('getAllDonations', () => { + it('should return all donations with donor relations', async () => { + const mockDonations = [ + { + id: '1', + name: 'Rice', + quantity: 50, + unit: 'kg', + status: 'AVAILABLE', + donor: { + id: 'donor-1', + name: 'Restaurant A', + }, + createdAt: new Date(), + }, + ]; + + mockDonationRepository.find.mockResolvedValue(mockDonations); + + const result = await controller.getAllDonations(); + + expect(mockDonationRepository.find).toHaveBeenCalledWith({ + relations: ['donor'], + order: { createdAt: 'DESC' }, + }); + expect(result).toEqual(mockDonations); + }); + }); +}); diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts new file mode 100644 index 00000000..bb0026c0 --- /dev/null +++ b/backend/src/admin/admin.controller.ts @@ -0,0 +1,83 @@ +import { Controller, Get, Patch, Param, UseGuards, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User, UserRole } from '../auth/entities/user.entity'; +import { Donation } from '../donations/entities/donation.entity'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; + +@UseGuards(JwtAuthGuard, RolesGuard) // 🛡️ LOCKED DOWN! +@Controller('admin') +export class AdminController { + constructor( + @InjectRepository(User) + private userRepository: Repository, + @InjectRepository(Donation) + private donationRepository: Repository, + ) {} + + // 1. Get all NGOs that are waiting for approval + @Get('pending-ngos') + async getPendingNgos() { + return await this.userRepository.find({ + where: { + role: UserRole.NGO, + isVerified: false, + }, + select: ['id', 'name', 'email', 'organizationName', 'phone', 'address', 'createdAt'], // Don't send passwords! + }); + } + + // 2. Approve/Verify an NGO + @Patch('verify/:id') + async verifyNgo(@Param('id') id: string) { + const user = await this.userRepository.findOne({ where: { id } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + user.isVerified = true; + await this.userRepository.save(user); + + return { message: `${user.organizationName || user.name} has been successfully verified!`, user }; + } + + // 3. Get ALL Users (For the main Admin User Table) + @Get('users') + async getAllUsers() { + return await this.userRepository.find({ + select: ['id', 'name', 'email', 'role', 'organizationName', 'isVerified', 'isActive', 'createdAt'], + order: { createdAt: 'DESC' } + }); + } + + // 4. Suspend or Unsuspend a User (Moderation - Epic 7, US 2) + @Patch('users/:id/toggle-status') + async toggleUserStatus(@Param('id') id: string) { + const user = await this.userRepository.findOne({ where: { id } }); + if (!user) throw new NotFoundException('User not found'); + + // Prevent the Super Admin from accidentally banning themselves + if (user.role === UserRole.ADMIN) { + throw new Error('Cannot suspend an administrator account'); + } + + user.isActive = !user.isActive; // Flip true to false, or false to true + await this.userRepository.save(user); + + return { + message: `User ${user.name} has been ${user.isActive ? 'unbanned' : 'suspended'}.`, + isActive: user.isActive + }; + } + + // 5. Get ALL Platform Donations (For the Admin Overview) + @Get('donations') + async getAllDonations() { + return await this.donationRepository.find({ + relations: ['donor'], // Loads the donor details so admin sees who posted it + order: { createdAt: 'DESC' } + }); + } +} diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts new file mode 100644 index 00000000..de332ad6 --- /dev/null +++ b/backend/src/admin/admin.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AdminController } from './admin.controller'; +import { User } from '../auth/entities/user.entity'; +import { Donation } from '../donations/entities/donation.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([User, Donation])], + controllers: [AdminController], +}) +export class AdminModule {} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 27b072b0..dc198b64 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,6 +5,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { DonationsModule } from './donations/donations.module'; +import { AdminModule } from './admin/admin.module'; import { User } from './auth/entities/user.entity'; import { Donation } from './donations/entities/donation.entity'; import { CacheModule } from '@nestjs/cache-manager'; @@ -40,6 +41,9 @@ import * as redisStore from 'cache-manager-redis-store'; AuthModule, DonationsModule, + + // Admin dashboard module + AdminModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/auth/entities/user.entity.ts b/backend/src/auth/entities/user.entity.ts index 4e7de969..148f20cc 100644 --- a/backend/src/auth/entities/user.entity.ts +++ b/backend/src/auth/entities/user.entity.ts @@ -22,6 +22,12 @@ export class User { @Column({ type: 'enum', enum: UserRole, default: UserRole.DONOR }) role: UserRole; + @Column({ default: false }) + isVerified: boolean; + + @Column({ default: true }) + isActive: boolean; // false = suspended/banned + @Column() name: string; diff --git a/backend/src/auth/guards/roles.guard.spec.ts b/backend/src/auth/guards/roles.guard.spec.ts new file mode 100644 index 00000000..725651d9 --- /dev/null +++ b/backend/src/auth/guards/roles.guard.spec.ts @@ -0,0 +1,86 @@ +import { RolesGuard } from './roles.guard'; +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { UserRole } from '../entities/user.entity'; + +describe('RolesGuard', () => { + let guard: RolesGuard; + + beforeEach(() => { + guard = new RolesGuard(); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + it('should allow access for ADMIN role', () => { + const mockContext = createMockExecutionContext({ + userId: 'admin-123', + email: 'admin@surplussync.com', + role: UserRole.ADMIN, + }); + + const result = guard.canActivate(mockContext); + + expect(result).toBe(true); + }); + + it('should deny access for DONOR role', () => { + const mockContext = createMockExecutionContext({ + userId: 'donor-123', + email: 'donor@example.com', + role: UserRole.DONOR, + }); + + expect(() => guard.canActivate(mockContext)).toThrow(ForbiddenException); + expect(() => guard.canActivate(mockContext)).toThrow( + 'Access Denied: Only Administrators can perform this action.', + ); + }); + + it('should deny access for NGO role', () => { + const mockContext = createMockExecutionContext({ + userId: 'ngo-123', + email: 'ngo@example.com', + role: UserRole.NGO, + }); + + expect(() => guard.canActivate(mockContext)).toThrow(ForbiddenException); + }); + + it('should deny access for VOLUNTEER role', () => { + const mockContext = createMockExecutionContext({ + userId: 'volunteer-123', + email: 'volunteer@example.com', + role: UserRole.VOLUNTEER, + }); + + expect(() => guard.canActivate(mockContext)).toThrow(ForbiddenException); + }); + + it('should deny access if no user is attached to request', () => { + const mockContext = createMockExecutionContext(null); + + expect(() => guard.canActivate(mockContext)).toThrow(ForbiddenException); + }); + + it('should deny access if user object exists but has no role', () => { + const mockContext = createMockExecutionContext({ + userId: 'user-123', + email: 'user@example.com', + }); + + expect(() => guard.canActivate(mockContext)).toThrow(ForbiddenException); + }); +}); + +// Helper function to create mock ExecutionContext +function createMockExecutionContext(user: any): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ + user, + }), + }), + } as ExecutionContext; +} diff --git a/backend/src/auth/guards/roles.guard.ts b/backend/src/auth/guards/roles.guard.ts new file mode 100644 index 00000000..5c48b6b7 --- /dev/null +++ b/backend/src/auth/guards/roles.guard.ts @@ -0,0 +1,15 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { UserRole } from '../entities/user.entity'; + +@Injectable() +export class RolesGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const user = request.user; // Injected by JwtAuthGuard + + if (!user || user.role !== UserRole.ADMIN) { + throw new ForbiddenException('Access Denied: Only Administrators can perform this action.'); + } + return true; + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 3421f8d0..8cc03ee0 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,6 +2,9 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User, UserRole } from './auth/entities/user.entity'; +import * as bcrypt from 'bcrypt'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -21,6 +24,28 @@ async function bootstrap() { }), ); + // --- SUPER ADMIN SEEDER START --- + const userRepository = app.get(getRepositoryToken(User)); + const adminEmail = process.env.SUPER_ADMIN_EMAIL || 'admin@surplussync.com'; + + const existingAdmin = await userRepository.findOne({ where: { role: UserRole.ADMIN } }); + + if (!existingAdmin) { + console.log('🌱 Seeding Super Admin account...'); + const hashedPassword = await bcrypt.hash(process.env.SUPER_ADMIN_PASSWORD || 'SecureAdmin123!', 10); + + await userRepository.save({ + name: 'System Admin', + email: adminEmail, + password: hashedPassword, + phone: '0000000000', + role: UserRole.ADMIN, + isVerified: true, + }); + console.log('✅ Super Admin seeded successfully!'); + } + // --- SUPER ADMIN SEEDER END --- + // Setup Swagger Documentation const config = new DocumentBuilder() .setTitle('Food Redistribution API') diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c264144a..fbbaf9e7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { Toaster } from 'sonner' import LandingPage from './pages/LandingPage' import Login from './pages/Login' @@ -13,8 +13,12 @@ import Impact from './pages/dashboard/Impact' import Notifications from './pages/dashboard/Notifications' import Profile from './pages/dashboard/Profile' import VolunteerDashboard from './pages/dashboard/VolunteerDashboard' +import AdminDashboard from './pages/dashboard/AdminDashboard' export default function App() { + const user = JSON.parse(localStorage.getItem('user') || 'null'); + const isAuthenticated = !!localStorage.getItem('token'); + return ( } /> } /> } /> - }> + + : + }> } /> } /> } /> @@ -38,6 +46,16 @@ export default function App() { } /> } /> + + ) : ( + + ) + } + /> ) diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 18dab381..5da8ecb2 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -7,6 +7,7 @@ vi.mock('react-router-dom', () => ({ Routes: ({ children }: any) => children, Route: ({ element }: any) => element, Link: ({ to, children }: any) => {children}, + Navigate: ({ to }: any) =>
, useNavigate: () => vi.fn(), useLocation: () => ({ pathname: '/', state: null }), Outlet: () =>
diff --git a/frontend/src/__tests__/pages/dashboard/AdminDashboard.test.tsx b/frontend/src/__tests__/pages/dashboard/AdminDashboard.test.tsx new file mode 100644 index 00000000..dd9ee0b5 --- /dev/null +++ b/frontend/src/__tests__/pages/dashboard/AdminDashboard.test.tsx @@ -0,0 +1,335 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import AdminDashboard from '../../../pages/dashboard/AdminDashboard'; +import { adminAPI } from '../../../services/api'; +import { toast } from 'sonner'; + +// Mock dependencies +vi.mock('../../../services/api', () => ({ + adminAPI: { + getPendingNGOs: vi.fn(), + verifyNGO: vi.fn(), + getAllUsers: vi.fn(), + toggleUserStatus: vi.fn(), + getAllDonations: vi.fn(), + }, +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => vi.fn(), +})); + +describe('AdminDashboard - Epic 7 User Story 1 & 2', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('should render the admin dashboard with title and description', () => { + vi.mocked(adminAPI.getPendingNGOs).mockResolvedValue({ data: [], status: 200, statusText: 'OK', headers: {}, config: {} } as any); + + render(); + + expect(screen.getByText('System Administration')).toBeTruthy(); + expect(screen.getByText(/Manage users, approve NGOs/i)).toBeTruthy(); + }); + + it('should render three tabs: Pending NGOs, All Users, Platform Donations', () => { + vi.mocked(adminAPI.getPendingNGOs).mockResolvedValue({ data: [], status: 200, statusText: 'OK', headers: {}, config: {} } as any); + + render(); + + expect(screen.getByText('Pending NGOs')).toBeTruthy(); + expect(screen.getByText('All Users')).toBeTruthy(); + expect(screen.getByText('Platform Donations')).toBeTruthy(); + }); + + it('should render Sign Out button', () => { + vi.mocked(adminAPI.getPendingNGOs).mockResolvedValue({ data: [], status: 200, statusText: 'OK', headers: {}, config: {} } as any); + + render(); + + expect(screen.getByText('Sign Out')).toBeTruthy(); + }); + + describe('Pending NGOs Tab (User Story 1)', () => { + it('should fetch and display pending NGOs on mount', async () => { + const mockPendingNGOs = [ + { + id: '1', + name: 'Food Bank A', + email: 'foodbank@example.com', + organizationName: 'Food Bank A', + role: 'NGO', + isVerified: false, + createdAt: '2026-02-20T00:00:00.000Z', + }, + ]; + + vi.mocked(adminAPI.getPendingNGOs).mockResolvedValue({ data: mockPendingNGOs, status: 200, statusText: 'OK', headers: {}, config: {} } as any); + + render(); + + await waitFor(() => { + expect(adminAPI.getPendingNGOs).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.getByText('Food Bank A')).toBeTruthy(); + expect(screen.getByText('foodbank@example.com')).toBeTruthy(); + }); + }); + + it('should display "No records found" when no pending NGOs exist', async () => { + vi.mocked(adminAPI.getPendingNGOs).mockResolvedValue({ data: [], status: 200, statusText: 'OK', headers: {}, config: {} } as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('No records found.')).toBeTruthy(); + }); + }); + + it('should call verifyNGO API when Approve button is clicked', async () => { + const mockPendingNGOs = [ + { + id: 'ngo-123', + name: 'Food Bank A', + email: 'foodbank@example.com', + organizationName: 'Food Bank A', + role: 'NGO', + createdAt: '2026-02-20T00:00:00.000Z', + }, + ]; + + vi.mocked(adminAPI.getPendingNGOs).mockResolvedValue({ data: mockPendingNGOs, status: 200, statusText: 'OK', headers: {}, config: {} } as any); + vi.mocked(adminAPI.verifyNGO).mockResolvedValue({ data: { message: 'Verified' }, status: 200, statusText: 'OK', headers: {}, config: {} } as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('Approve')).toBeTruthy(); + }); + + const approveButton = screen.getByText('Approve'); + fireEvent.click(approveButton); + + await waitFor(() => { + expect(adminAPI.verifyNGO).toHaveBeenCalledWith('ngo-123'); + expect(toast.success).toHaveBeenCalledWith('NGO Verified Successfully'); + }); + }); + }); + + describe('All Users Tab (User Story 2)', () => { + it('should fetch and display all users when switching to Users tab', async () => { + const mockUsers = [ + { + id: '1', + name: 'John Donor', + email: 'john@example.com', + role: 'DONOR', + isVerified: true, + isActive: true, + createdAt: '2026-02-20T00:00:00.000Z', + }, + ]; + + vi.mocked(adminAPI.getPendingNGOs).mockResolvedValue({ data: [], status: 200, statusText: 'OK', headers: {}, config: {} } as any); + vi.mocked(adminAPI.getAllUsers).mockResolvedValue({ data: mockUsers, status: 200, statusText: 'OK', headers: {}, config: {} } as any); + + render(); + + const usersTab = screen.getByText('All Users'); + fireEvent.click(usersTab); + + await waitFor(() => { + expect(adminAPI.getAllUsers).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.getByText('John Donor')).toBeTruthy(); + expect(screen.getByText('john@example.com')).toBeTruthy(); + expect(screen.getByText('Active')).toBeTruthy(); + }); + }); + + it('should display suspended status for inactive users', async () => { + const mockUsers = [ + { + id: '1', + name: 'Banned User', + email: 'banned@example.com', + role: 'DONOR', + isActive: false, + createdAt: '2026-02-20T00:00:00.000Z', + }, + ]; + + vi.mocked(adminAPI.getPendingNGOs).mockResolvedValue({ data: [], status: 200, statusText: 'OK', headers: {}, config: {} } as any); + vi.mocked(adminAPI.getAllUsers).mockResolvedValue({ data: mockUsers, status: 200, statusText: 'OK', headers: {}, config: {} } as any); + + render(); + + const usersTab = screen.getByText('All Users'); + fireEvent.click(usersTab); + + await waitFor(() => { + expect(screen.getByText('Suspended')).toBeTruthy(); + }); + }); + + it('should call toggleUserStatus API when Suspend button is clicked', async () => { + const mockUsers = [ + { + id: 'user-123', + name: 'Active User', + email: 'user@example.com', + role: 'DONOR', + isActive: true, + createdAt: '2026-02-20T00:00:00.000Z', + }, + ]; + + vi.mocked(adminAPI.getPendingNGOs).mockResolvedValue({ data: [], status: 200, statusText: 'OK', headers: {}, config: {} } as any); + vi.mocked(adminAPI.getAllUsers).mockResolvedValue({ data: mockUsers, status: 200, statusText: 'OK', headers: {}, config: {} } as any); + vi.mocked(adminAPI.toggleUserStatus).mockResolvedValue({ + data: { message: 'User suspended', isActive: false }, + status: 200, + statusText: 'OK', + headers: {}, + config: {} + } as any); + + render(); + + const usersTab = screen.getByText('All Users'); + fireEvent.click(usersTab); + + await waitFor(() => { + expect(screen.getByText('Suspend')).toBeTruthy(); + }); + + const suspendButton = screen.getByText('Suspend'); + fireEvent.click(suspendButton); + + await waitFor(() => { + expect(adminAPI.toggleUserStatus).toHaveBeenCalledWith('user-123'); + expect(toast.success).toHaveBeenCalledWith('User suspended'); + }); + }); + + it('should not show suspend button for ADMIN users', async () => { + const mockUsers = [ + { + id: 'admin-123', + name: 'System Admin', + email: 'admin@surplussync.com', + role: 'ADMIN', + isActive: true, + createdAt: '2026-02-20T00:00:00.000Z', + }, + ]; + + vi.mocked(adminAPI.getPendingNGOs).mockResolvedValue({ data: [], status: 200, statusText: 'OK', headers: {}, config: {} } as any); + vi.mocked(adminAPI.getAllUsers).mockResolvedValue({ data: mockUsers, status: 200, statusText: 'OK', headers: {}, config: {} } as any); + + render(); + + const usersTab = screen.getByText('All Users'); + fireEvent.click(usersTab); + + await waitFor(() => { + expect(screen.getByText('System Admin')).toBeTruthy(); + }); + + // Should not have Suspend or Restore button for admin + expect(screen.queryByText('Suspend')).toBeNull(); + expect(screen.queryByText('Restore')).toBeNull(); + }); + }); + + describe('Platform Donations Tab (User Story 3)', () => { + it('should fetch and display all donations when switching to Donations tab', async () => { + const mockDonations = [ + { + id: '1', + name: 'Rice', + foodType: 'packaged', + quantity: 50, + unit: 'kg', + status: 'AVAILABLE', + donor: { + email: 'donor@example.com', + }, + createdAt: '2026-02-20T00:00:00.000Z', + }, + ]; + + vi.mocked(adminAPI.getPendingNGOs).mockResolvedValue({ data: [], status: 200, statusText: 'OK', headers: {}, config: {} } as any); + vi.mocked(adminAPI.getAllDonations).mockResolvedValue({ data: mockDonations, status: 200, statusText: 'OK', headers: {}, config: {} } as any); + + render(); + + const donationsTab = screen.getByText('Platform Donations'); + fireEvent.click(donationsTab); + + await waitFor(() => { + expect(adminAPI.getAllDonations).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.getByText('Rice')).toBeTruthy(); + expect(screen.getByText('50 kg')).toBeTruthy(); + expect(screen.getByText('donor@example.com')).toBeTruthy(); + expect(screen.getByText('AVAILABLE')).toBeTruthy(); + }); + }); + }); + + describe('Error Handling', () => { + it('should show error toast when fetching pending NGOs fails', async () => { + vi.mocked(adminAPI.getPendingNGOs).mockRejectedValue(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to fetch admin data'); + }); + }); + + it('should show error toast when verifying NGO fails', async () => { + const mockPendingNGOs = [ + { + id: 'ngo-123', + name: 'Food Bank A', + email: 'foodbank@example.com', + createdAt: '2026-02-20T00:00:00.000Z', + }, + ]; + + vi.mocked(adminAPI.getPendingNGOs).mockResolvedValue({ data: mockPendingNGOs, status: 200, statusText: 'OK', headers: {}, config: {} } as any); + vi.mocked(adminAPI.verifyNGO).mockRejectedValue(new Error('Verification failed')); + + render(); + + await waitFor(() => { + expect(screen.getByText('Approve')).toBeTruthy(); + }); + + const approveButton = screen.getByText('Approve'); + fireEvent.click(approveButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to verify NGO'); + }); + }); + }); +}); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 560e6ce7..df0a4cda 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -22,7 +22,12 @@ export default function Login() { localStorage.setItem('token', data.token) localStorage.setItem('user', JSON.stringify(data.user)) - navigate('/dashboard') + // Route based on role + if (data.user.role === 'ADMIN') { + navigate('/admin-dashboard') + } else { + navigate('/dashboard') + } } catch (err: any) { // Handle Axios errors safely const msg = err.response?.data?.message || err.message || 'Login failed' diff --git a/frontend/src/pages/dashboard/AdminDashboard.tsx b/frontend/src/pages/dashboard/AdminDashboard.tsx new file mode 100644 index 00000000..a5e37eb7 --- /dev/null +++ b/frontend/src/pages/dashboard/AdminDashboard.tsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { adminAPI } from '../../services/api'; +import { toast } from 'sonner'; +import { ShieldAlert, CheckCircle, Ban, Activity, LogOut } from 'lucide-react'; + +export default function AdminDashboard() { + const [activeTab, setActiveTab] = useState<'pending' | 'users' | 'donations'>('pending'); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + fetchData(); + }, [activeTab]); + + const fetchData = async () => { + setLoading(true); + try { + let res; + if (activeTab === 'pending') res = await adminAPI.getPendingNGOs(); + if (activeTab === 'users') res = await adminAPI.getAllUsers(); + if (activeTab === 'donations') res = await adminAPI.getAllDonations(); + setData(res?.data || []); + } catch (error) { + toast.error('Failed to fetch admin data'); + } finally { + setLoading(false); + } + }; + + const handleVerify = async (id: string) => { + try { + await adminAPI.verifyNGO(id); + toast.success('NGO Verified Successfully'); + fetchData(); + } catch (error) { + toast.error('Failed to verify NGO'); + } + }; + + const handleToggleStatus = async (id: string) => { + try { + const res = await adminAPI.toggleUserStatus(id); + toast.success(res.data.message); + fetchData(); + } catch (error) { + toast.error('Failed to update user status'); + } + }; + + const handleSignOut = () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + toast.success('Signed out successfully'); + navigate('/login'); + }; + + return ( +
+
+ {/* Top Bar with Sign Out */} +
+ +
+ +
+
+

+ + System Administration +

+

Manage users, approve NGOs, and monitor platform health.

+
+
+ + {/* Minimalist Tabs */} +
+ {['pending', 'users', 'donations'].map((tab) => ( + + ))} +
+ + {/* Data Table */} +
+ {loading ? ( +
Loading data...
+ ) : data.length === 0 ? ( +
No records found.
+ ) : ( + + + + + + + + + + + {data.map((item) => ( + + + + + + + ))} + +
Name / OrgEmailDate / StatusActions
+
{item.organizationName || item.name || item.foodType}
+
{item.role || `${item.quantity} ${item.unit}`}
+
{item.email || item.donor?.email || 'N/A'} + {new Date(item.createdAt).toLocaleDateString()} + {activeTab === 'users' && ( + + {item.isActive ? 'Active' : 'Suspended'} + + )} + + {activeTab === 'pending' && ( + + )} + {activeTab === 'users' && item.role !== 'ADMIN' && ( + + )} + {activeTab === 'donations' && ( + + {item.status} + + )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e79eafa3..f9b32034 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -261,4 +261,15 @@ export const checkExpiringDonations = () => { return; }; +// --- ADMIN API --- +export const adminAPI = { + getPendingNGOs: () => api.get('/admin/pending-ngos'), + verifyNGO: (id: string) => api.patch(`/admin/verify/${id}`), + + getAllUsers: () => api.get('/admin/users'), + toggleUserStatus: (id: string) => api.patch(`/admin/users/${id}/toggle-status`), + + getAllDonations: () => api.get('/admin/donations'), +}; + export default api; \ No newline at end of file