Merge branch 'main' of https://gitea.yaltopia.com/YaltopiaTech/Yaltopia-Ticket-Admin
Some checks failed
CI / Test & Build (18.x) (push) Has been cancelled
CI / Test & Build (20.x) (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
Build Production / Build Production Artifacts (push) Has been cancelled

This commit is contained in:
Yared Yemane 2026-02-28 05:59:16 -08:00
commit 2f8bd423ca
85 changed files with 8464 additions and 1324 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
node_modules
dist
.git
.gitignore
.env
.env.local
.env.production
.env.development
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
.vscode
.idea
README.md

11
.env.example Normal file
View File

@ -0,0 +1,11 @@
# Backend API Configuration
VITE_BACKEND_API_URL=http://localhost:3000/api/v1
# Environment
VITE_ENV=development
# Optional: Analytics
# VITE_ANALYTICS_ID=
# Optional: Sentry Error Tracking
# VITE_SENTRY_DSN=

11
.env.production.example Normal file
View File

@ -0,0 +1,11 @@
# Backend API Configuration
VITE_BACKEND_API_URL=https://api.yourdomain.com/api/v1
# Environment
VITE_ENV=production
# Optional: Analytics
# VITE_ANALYTICS_ID=your-analytics-id
# Optional: Sentry Error Tracking
# VITE_SENTRY_DSN=your-sentry-dsn

85
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,85 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
name: Test & Build
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run type check
run: npm run type-check
- name: Run tests
run: npm run test:run
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/coverage-final.json
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
- name: Build application
run: npm run build
env:
VITE_BACKEND_API_URL: ${{ secrets.VITE_BACKEND_API_URL }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.node-version }}
path: dist/
retention-days: 7
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: Run npm audit
run: npm audit --audit-level=moderate
continue-on-error: true
- name: Run Snyk security scan
uses: snyk/actions/node@master
continue-on-error: true
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

52
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: Build Production
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
name: Build Production Artifacts
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test:run
- name: Build for production
run: npm run build:prod
env:
VITE_BACKEND_API_URL: ${{ secrets.VITE_BACKEND_API_URL_PROD }}
VITE_ENV: production
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
- name: Upload production artifacts
uses: actions/upload-artifact@v4
with:
name: production-build
path: dist/
retention-days: 30
- name: Build Docker image
run: docker build -t yaltopia-admin:${{ github.sha }} .
- name: Build success notification
if: success()
run: echo "Production build successful!"
- name: Build failure notification
if: failure()
run: echo "Production build failed!"

7
.gitignore vendored
View File

@ -10,8 +10,15 @@ lerna-debug.log*
node_modules
dist
dist-ssr
coverage
*.local
# Environment variables
.env
.env.local
.env.production
.env.development
# Editor directories and files
.vscode/*
!.vscode/extensions.json

35
Dockerfile Normal file
View File

@ -0,0 +1,35 @@
# Build stage
FROM node:18-alpine as build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Build the application
RUN npm run build:prod
# Production stage
FROM nginx:alpine
# Copy built assets from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

278
README.md
View File

@ -1,73 +1,233 @@
# React + TypeScript + Vite
# Yaltopia Ticket Admin
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Admin dashboard for Yaltopia Ticket management system built with React, TypeScript, and Vite.
Currently, two official plugins are available:
> 📚 **For detailed documentation, see [dev-docs/](./dev-docs/README.md)**
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Features
## React Compiler
- User Management
- Analytics Dashboard
- Security Monitoring
- System Health Monitoring
- Audit Logs
- Announcements Management
- Maintenance Mode
- API Key Management
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Tech Stack
## Expanding the ESLint configuration
- React 19
- TypeScript
- Vite
- TanStack Query (React Query)
- React Router v7
- Tailwind CSS
- Radix UI Components
- Recharts for data visualization
- Axios for API calls
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
## Prerequisites
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
- Node.js 18+
- npm or yarn
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
## Getting Started
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
### 1. Clone the repository
```bash
git clone <repository-url>
cd yaltopia-ticket-admin
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
### 2. Install dependencies
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```bash
npm install
```
### 3. Environment Configuration
Copy the example environment file and configure it:
```bash
cp .env.example .env
```
Edit `.env` and set your API URL:
```env
VITE_BACKEND_API_URL=http://localhost:3000/api/v1
VITE_ENV=development
```
### 4. Run development server
```bash
npm run dev
```
The application will be available at `http://localhost:5173`
## Building for Production
### 1. Configure production environment
Copy the production environment example:
```bash
cp .env.production.example .env.production
```
Edit `.env.production` with your production API URL:
```env
VITE_BACKEND_API_URL=https://api.yourdomain.com/api/v1
VITE_ENV=production
```
### 2. Build the application
```bash
npm run build:prod
```
The production build will be in the `dist` directory.
### 3. Preview production build locally
```bash
npm run preview
```
## Deployment
### Docker Deployment (Recommended)
1. Build the Docker image:
```bash
docker build -t yaltopia-admin:latest .
```
2. Run the container:
```bash
docker run -p 8080:80 yaltopia-admin:latest
```
3. Deploy to your cloud provider (AWS, GCP, Azure, DigitalOcean, etc.)
### Traditional VPS Deployment
1. Build the application: `npm run build:prod`
2. Copy the `dist` directory to your web server
3. Configure nginx or Apache to serve the static files
4. Set up redirects for SPA routing (see nginx.conf example)
### SPA Routing Configuration
For proper routing with nginx, use the included `nginx.conf` file or add this configuration:
```nginx
location / {
try_files $uri $uri/ /index.html;
}
```
The project includes a `Dockerfile` and `nginx.conf` for containerized deployment.
See [DEPLOYMENT.md](dev-docs/DEPLOYMENT.md) for detailed deployment instructions.
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
```
Build and run:
```bash
docker build -t yaltopia-admin .
docker run -p 80:80 yaltopia-admin
```
## Environment Variables
| Variable | Description | Required | Default |
|----------|-------------|----------|---------|
| `VITE_BACKEND_API_URL` | Backend API base URL | Yes | `http://localhost:3000/api/v1` |
| `VITE_ENV` | Environment name | No | `development` |
| `VITE_ANALYTICS_ID` | Analytics tracking ID | No | - |
| `VITE_SENTRY_DSN` | Sentry error tracking DSN | No | - |
## Scripts
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run build:prod` - Build with production environment
- `npm run preview` - Preview production build
- `npm run lint` - Run ESLint
- `npm run lint:fix` - Fix ESLint errors
- `npm run type-check` - Run TypeScript type checking
## Project Structure
```
src/
├── app/ # App configuration (query client)
├── assets/ # Static assets
├── components/ # Reusable UI components
│ └── ui/ # Shadcn UI components
├── layouts/ # Layout components
├── lib/ # Utilities and API client
├── pages/ # Page components
│ ├── admin/ # Admin pages
│ ├── dashboard/ # Dashboard pages
│ └── ...
├── App.tsx # Main app component
├── main.tsx # App entry point
└── index.css # Global styles
```
## Security Considerations
### Current Implementation
- JWT tokens stored in localStorage
- Token automatically attached to API requests
- Automatic redirect to login on 401 errors
- Error handling for common HTTP status codes
### Production Recommendations
1. **Use httpOnly cookies** instead of localStorage for tokens
2. **Implement HTTPS** - Never deploy without SSL/TLS
3. **Add security headers** - CSP, HSTS, X-Frame-Options
4. **Enable CORS** properly on your backend
5. **Implement rate limiting** on authentication endpoints
6. **Add error boundary** for graceful error handling
7. **Set up monitoring** (Sentry, LogRocket, etc.)
8. **Regular security audits** - Run `npm audit` regularly
## Browser Support
- Chrome (latest)
- Firefox (latest)
- Safari (latest)
- Edge (latest)
## Contributing
1. Create a feature branch
2. Make your changes
3. Run linting and type checking
4. Submit a pull request
## License
Proprietary - All rights reserved

983
dev-docs/API_GUIDE.md Normal file
View File

@ -0,0 +1,983 @@
# API & Service Layer Guide
Complete guide for making API calls in the Yaltopia Ticket Admin application.
## Table of Contents
- [Architecture Overview](#architecture-overview)
- [Available Services](#available-services)
- [Basic Usage](#basic-usage)
- [Common Patterns](#common-patterns)
- [Service Methods Reference](#service-methods-reference)
- [Error Handling](#error-handling)
- [Best Practices](#best-practices)
- [Examples](#examples)
---
## Architecture Overview
### The Service Layer Pattern
All API calls flow through a centralized service layer:
```
┌─────────────────┐
│ Component │ "I need user data"
└────────┬────────┘
┌─────────────────┐
│ Service Layer │ userService.getUsers()
│ (Business │ • Type-safe methods
│ Logic) │ • Error handling
└────────┬────────┘ • Data transformation
┌─────────────────┐
│ API Client │ axios.get('/admin/users')
│ (HTTP Layer) │ • Auth token injection
└────────┬────────┘ • Token refresh
│ • Request/response interceptors
┌─────────────────┐
│ Backend API │ Returns JSON data
└─────────────────┘
```
### Why Service Layer?
**Before (Bad):**
```typescript
// Direct API calls - scattered everywhere
const response = await axios.get('/api/users')
const users = response.data
// Different patterns in different files
fetch('/api/users').then(r => r.json())
```
**After (Good):**
```typescript
// Centralized, typed, consistent
import { userService } from '@/services'
const users = await userService.getUsers()
```
**Benefits:**
- ✅ Single source of truth
- ✅ Type safety (TypeScript)
- ✅ Automatic authentication
- ✅ Consistent error handling
- ✅ Easy to test
- ✅ Easy to maintain
---
## Available Services
### Import All Services
```typescript
import {
authService, // Authentication & authorization
userService, // User management
analyticsService, // Dashboard analytics & metrics
securityService, // Security monitoring & logs
systemService, // System health & maintenance
announcementService,// Announcements management
auditService, // Audit logs & history
settingsService // System settings
} from '@/services'
```
### Service Locations
```
src/services/
├── index.ts # Central export (import from here)
├── api/
│ └── client.ts # Shared axios instance
├── auth.service.ts # authService
├── user.service.ts # userService
├── analytics.service.ts # analyticsService
├── security.service.ts # securityService
├── system.service.ts # systemService
├── announcement.service.ts # announcementService
├── audit.service.ts # auditService
└── settings.service.ts # settingsService
```
---
## Basic Usage
### 1. Simple API Call
```typescript
import { userService } from '@/services'
// Async/await
async function loadUsers() {
const users = await userService.getUsers()
console.log(users) // Typed response
}
// Promise
userService.getUsers()
.then(users => console.log(users))
.catch(error => console.error(error))
```
### 2. With React Query (Recommended)
```typescript
import { useQuery } from '@tanstack/react-query'
import { userService } from '@/services'
function UsersPage() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => userService.getUsers()
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
{data?.data.map(user => (
<div key={user.id}>{user.email}</div>
))}
</div>
)
}
```
### 3. With Parameters
```typescript
import { userService } from '@/services'
// Fetch with filters
const users = await userService.getUsers({
page: 1,
limit: 20,
search: 'john',
role: 'ADMIN',
isActive: true
})
// Single user
const user = await userService.getUser('user-id-123')
```
### 4. Mutations (Create/Update/Delete)
```typescript
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { userService } from '@/services'
import { toast } from 'sonner'
function DeleteUserButton({ userId }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: () => userService.deleteUser(userId),
onSuccess: () => {
// Refresh the users list
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success('User deleted successfully')
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to delete user')
}
})
return (
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Deleting...' : 'Delete User'}
</button>
)
}
```
---
## Common Patterns
### Pattern 1: Fetching List Data
```typescript
import { useQuery } from '@tanstack/react-query'
import { userService } from '@/services'
import { useState } from 'react'
function UsersPage() {
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['users', page, search],
queryFn: () => userService.getUsers({ page, limit: 20, search })
})
return (
<div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
/>
{isLoading ? (
<div>Loading...</div>
) : (
<div>
{data?.data.map(user => (
<UserCard key={user.id} user={user} />
))}
<Pagination
page={page}
totalPages={data?.totalPages}
onPageChange={setPage}
/>
</div>
)}
</div>
)
}
```
### Pattern 2: Creating New Records
```typescript
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { userService } from '@/services'
import { useState } from 'react'
function CreateUserForm() {
const queryClient = useQueryClient()
const [formData, setFormData] = useState({
email: '',
firstName: '',
lastName: '',
role: 'USER'
})
const createMutation = useMutation({
mutationFn: (data) => userService.createUser(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success('User created successfully')
setFormData({ email: '', firstName: '', lastName: '', role: 'USER' })
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to create user')
}
})
const handleSubmit = (e) => {
e.preventDefault()
createMutation.mutate(formData)
}
return (
<form onSubmit={handleSubmit}>
<input
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="Email"
required
/>
{/* More fields... */}
<button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create User'}
</button>
</form>
)
}
```
### Pattern 3: Updating Records
```typescript
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { userService } from '@/services'
function UpdateUserButton({ userId, updates }) {
const queryClient = useQueryClient()
const updateMutation = useMutation({
mutationFn: () => userService.updateUser(userId, updates),
onSuccess: () => {
// Refresh both the list and the single user
queryClient.invalidateQueries({ queryKey: ['users'] })
queryClient.invalidateQueries({ queryKey: ['users', userId] })
toast.success('User updated successfully')
}
})
return (
<button onClick={() => updateMutation.mutate()}>
Update User
</button>
)
}
```
### Pattern 4: File Upload
```typescript
import { userService } from '@/services'
function ImportUsersButton() {
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
const result = await userService.importUsers(file)
toast.success(`Imported ${result.imported} users. ${result.failed} failed.`)
} catch (error: any) {
toast.error(error.response?.data?.message || 'Import failed')
}
}
return (
<input
type="file"
accept=".csv"
onChange={handleFileUpload}
/>
)
}
```
### Pattern 5: File Download
```typescript
import { userService } from '@/services'
function ExportUsersButton() {
const handleExport = async () => {
try {
const blob = await userService.exportUsers('csv')
// Create download link
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `users-${new Date().toISOString()}.csv`
a.click()
window.URL.revokeObjectURL(url)
toast.success('Users exported successfully')
} catch (error: any) {
toast.error('Export failed')
}
}
return (
<button onClick={handleExport}>
Export Users
</button>
)
}
```
---
## Service Methods Reference
### AuthService
```typescript
import { authService } from '@/services'
// Login
const response = await authService.login({
email: 'admin@example.com',
password: 'password'
})
// Returns: { accessToken, refreshToken, user }
// Logout
await authService.logout()
// Refresh token
await authService.refreshToken()
// Get current user (from localStorage)
const user = authService.getCurrentUser()
// Check if authenticated
const isAuth = authService.isAuthenticated()
// Check if admin
const isAdmin = authService.isAdmin()
```
### UserService
```typescript
import { userService } from '@/services'
// Get paginated users
const users = await userService.getUsers({
page: 1,
limit: 20,
search: 'john',
role: 'ADMIN',
isActive: true
})
// Get single user
const user = await userService.getUser('user-id')
// Create user
const newUser = await userService.createUser({
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'USER'
})
// Update user
const updated = await userService.updateUser('user-id', {
isActive: false,
role: 'ADMIN'
})
// Delete user
await userService.deleteUser('user-id', hard = false)
// Reset password
const result = await userService.resetPassword('user-id')
// Returns: { temporaryPassword: 'abc123' }
// Get user activity
const activity = await userService.getUserActivity('user-id', days = 30)
// Import users from CSV
const result = await userService.importUsers(file)
// Returns: { imported: 10, failed: 2 }
// Export users to CSV
const blob = await userService.exportUsers('csv')
```
### AnalyticsService
```typescript
import { analyticsService } from '@/services'
// Get overview statistics
const stats = await analyticsService.getOverview()
// Returns: { totalUsers, activeUsers, totalRevenue, ... }
// Get user growth data
const growth = await analyticsService.getUserGrowth(days = 30)
// Returns: [{ date: '2024-01-01', users: 100, ... }]
// Get revenue data
const revenue = await analyticsService.getRevenue('30days')
// Options: '7days', '30days', '90days'
// Get API usage
const apiUsage = await analyticsService.getApiUsage(days = 7)
// Get error rate
const errorRate = await analyticsService.getErrorRate(days = 7)
// Get storage analytics
const storage = await analyticsService.getStorageAnalytics()
```
### SecurityService
```typescript
import { securityService } from '@/services'
// Get suspicious activity
const suspicious = await securityService.getSuspiciousActivity()
// Returns: { suspiciousIPs: [...], suspiciousEmails: [...] }
// Get active sessions
const sessions = await securityService.getActiveSessions()
// Terminate session
await securityService.terminateSession('session-id')
// Get failed login attempts
const failedLogins = await securityService.getFailedLogins({
page: 1,
limit: 50,
email: 'user@example.com'
})
// Get rate limit violations
const violations = await securityService.getRateLimitViolations(days = 7)
// Get all API keys
const apiKeys = await securityService.getAllApiKeys()
// Revoke API key
await securityService.revokeApiKey('key-id')
// Ban IP address
await securityService.banIpAddress('192.168.1.1', 'Suspicious activity')
```
### SystemService
```typescript
import { systemService } from '@/services'
// Get system health
const health = await systemService.getHealth()
// Returns: { status: 'healthy', database: 'connected', ... }
// Get system info
const info = await systemService.getSystemInfo()
// Returns: { version, environment, memory, cpu, ... }
// Get maintenance status
const maintenance = await systemService.getMaintenanceStatus()
// Enable maintenance mode
await systemService.enableMaintenance('System upgrade in progress')
// Disable maintenance mode
await systemService.disableMaintenance()
// Clear cache
await systemService.clearCache()
// Run migrations
const result = await systemService.runMigrations()
```
### AnnouncementService
```typescript
import { announcementService } from '@/services'
// Get all announcements
const announcements = await announcementService.getAnnouncements(activeOnly = false)
// Get single announcement
const announcement = await announcementService.getAnnouncement('announcement-id')
// Create announcement
const newAnnouncement = await announcementService.createAnnouncement({
title: 'Maintenance Notice',
message: 'System will be down for maintenance',
type: 'warning',
priority: 1,
targetAudience: 'all'
})
// Update announcement
const updated = await announcementService.updateAnnouncement('announcement-id', {
title: 'Updated Title'
})
// Toggle announcement active status
await announcementService.toggleAnnouncement('announcement-id')
// Delete announcement
await announcementService.deleteAnnouncement('announcement-id')
```
### AuditService
```typescript
import { auditService } from '@/services'
// Get audit logs
const logs = await auditService.getAuditLogs({
page: 1,
limit: 50,
userId: 'user-id',
action: 'DELETE',
resourceType: 'user',
startDate: '2024-01-01',
endDate: '2024-12-31'
})
// Get audit log by ID
const log = await auditService.getAuditLog('log-id')
// Get user audit activity
const activity = await auditService.getUserAuditActivity('user-id', days = 30)
// Get resource history
const history = await auditService.getResourceHistory('user', 'resource-id')
// Get audit statistics
const stats = await auditService.getAuditStats(startDate, endDate)
// Export audit logs
const blob = await auditService.exportAuditLogs({
format: 'csv',
startDate: '2024-01-01',
endDate: '2024-12-31'
})
```
### SettingsService
```typescript
import { settingsService } from '@/services'
// Get all settings
const settings = await settingsService.getSettings(category = 'GENERAL')
// Get single setting
const setting = await settingsService.getSetting('feature_flag')
// Create setting
const newSetting = await settingsService.createSetting({
key: 'feature_flag',
value: 'true',
category: 'FEATURES',
description: 'Enable new feature',
isPublic: false
})
// Update setting
const updated = await settingsService.updateSetting('feature_flag', {
value: 'false'
})
// Delete setting
await settingsService.deleteSetting('feature_flag')
// Get public settings (for frontend use)
const publicSettings = await settingsService.getPublicSettings()
```
---
## Error Handling
### Standard Error Pattern
All services throw errors with consistent structure:
```typescript
try {
await userService.deleteUser(userId)
toast.success('User deleted')
} catch (error: any) {
// Error structure:
// error.response.status - HTTP status code
// error.response.data.message - Error message from backend
const message = error.response?.data?.message || 'Operation failed'
toast.error(message)
}
```
### Common HTTP Status Codes
```typescript
// 400 - Bad Request (validation error)
catch (error: any) {
if (error.response?.status === 400) {
toast.error('Invalid input: ' + error.response.data.message)
}
}
// 401 - Unauthorized (handled automatically by interceptor)
// Token refresh attempted automatically
// If refresh fails, user redirected to login
// 403 - Forbidden (no permission)
catch (error: any) {
if (error.response?.status === 403) {
toast.error('You do not have permission to perform this action')
}
}
// 404 - Not Found
catch (error: any) {
if (error.response?.status === 404) {
toast.error('Resource not found')
}
}
// 500 - Server Error
catch (error: any) {
if (error.response?.status === 500) {
toast.error('Server error. Please try again later.')
}
}
```
### React Query Error Handling
```typescript
const { data, error } = useQuery({
queryKey: ['users'],
queryFn: () => userService.getUsers(),
retry: 3, // Retry failed requests
retryDelay: 1000, // Wait 1s between retries
})
// Display error
if (error) {
return <div>Error: {error.message}</div>
}
```
---
## Best Practices
### ✅ DO: Use Services
```typescript
// Good
import { userService } from '@/services'
const users = await userService.getUsers()
```
### ❌ DON'T: Direct API Calls
```typescript
// Bad - don't do this
import axios from 'axios'
const response = await axios.get('/api/users')
```
### ✅ DO: Use React Query
```typescript
// Good - caching, loading states, error handling
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => userService.getUsers()
})
```
### ❌ DON'T: Manual State Management
```typescript
// Bad - manual state management
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
userService.getUsers()
.then(setUsers)
.finally(() => setLoading(false))
}, [])
```
### ✅ DO: Specific Query Keys
```typescript
// Good - specific, cacheable
queryKey: ['users', page, limit, search]
```
### ❌ DON'T: Generic Query Keys
```typescript
// Bad - too generic
queryKey: ['data']
```
### ✅ DO: Invalidate After Mutations
```typescript
// Good - refresh data after changes
const mutation = useMutation({
mutationFn: userService.deleteUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
}
})
```
### ✅ DO: Handle Errors
```typescript
// Good - user feedback
try {
await userService.deleteUser(id)
toast.success('User deleted')
} catch (error: any) {
toast.error(error.response?.data?.message)
}
```
### ❌ DON'T: Ignore Errors
```typescript
// Bad - no error handling
await userService.deleteUser(id)
```
---
## Examples
### Complete CRUD Example
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { userService } from '@/services'
import { toast } from 'sonner'
import { useState } from 'react'
function UsersManagement() {
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [editingUser, setEditingUser] = useState(null)
// READ - Fetch users
const { data: users, isLoading } = useQuery({
queryKey: ['users', page],
queryFn: () => userService.getUsers({ page, limit: 20 })
})
// CREATE - Add new user
const createMutation = useMutation({
mutationFn: (data) => userService.createUser(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success('User created')
}
})
// UPDATE - Edit user
const updateMutation = useMutation({
mutationFn: ({ id, data }) => userService.updateUser(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
setEditingUser(null)
toast.success('User updated')
}
})
// DELETE - Remove user
const deleteMutation = useMutation({
mutationFn: (id) => userService.deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success('User deleted')
}
})
if (isLoading) return <div>Loading...</div>
return (
<div>
<h1>Users Management</h1>
{/* User List */}
<table>
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users?.data.map(user => (
<tr key={user.id}>
<td>{user.email}</td>
<td>{user.firstName} {user.lastName}</td>
<td>{user.role}</td>
<td>
<button onClick={() => setEditingUser(user)}>
Edit
</button>
<button onClick={() => deleteMutation.mutate(user.id)}>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
<div>
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
>
Previous
</button>
<span>Page {page} of {users?.totalPages}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page === users?.totalPages}
>
Next
</button>
</div>
</div>
)
}
```
---
## Summary
### Key Takeaways
1. **Always use services** - Never make direct API calls
2. **Use React Query** - For data fetching and caching
3. **Handle errors** - Provide user feedback
4. **Invalidate queries** - After mutations
5. **Use specific query keys** - For better caching
### Quick Reference
```typescript
// Import
import { userService } from '@/services'
// Fetch
const { data } = useQuery({
queryKey: ['users'],
queryFn: () => userService.getUsers()
})
// Mutate
const mutation = useMutation({
mutationFn: (id) => userService.deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success('Success')
}
})
// Error handling
try {
await userService.deleteUser(id)
} catch (error: any) {
toast.error(error.response?.data?.message)
}
```
---
**For more information:**
- [Development Guide](./DEVELOPMENT.md) - General development practices
- [Testing Guide](./TESTING_GUIDE.md) - How to test services
- [Security Guide](./SECURITY.md) - Security best practices

255
dev-docs/DEPLOYMENT.md Normal file
View File

@ -0,0 +1,255 @@
# Deployment Guide
## Pre-Deployment Checklist
### 1. Code Quality
- [ ] All TypeScript errors resolved
- [ ] ESLint warnings addressed
- [ ] Build completes successfully
- [ ] No console errors in production build
### 2. Environment Configuration
- [ ] `.env.production` configured with production API URL
- [ ] All required environment variables set
- [ ] API endpoints tested and accessible
- [ ] CORS configured on backend for production domain
### 3. Security
- [ ] HTTPS enabled (SSL/TLS certificate)
- [ ] Security headers configured (CSP, HSTS, X-Frame-Options)
- [ ] Authentication tokens secured (consider httpOnly cookies)
- [ ] API keys and secrets not exposed in client code
- [ ] Rate limiting configured on backend
- [ ] Input validation on all forms
### 4. Performance
- [ ] Code splitting implemented (check vite.config.ts)
- [ ] Images optimized
- [ ] Lazy loading for routes (if needed)
- [ ] Bundle size analyzed and optimized
- [ ] CDN configured for static assets (optional)
### 5. Monitoring & Error Tracking
- [ ] Error boundary implemented ✓
- [ ] Error tracking service configured (Sentry, LogRocket, etc.)
- [ ] Analytics configured (Google Analytics, Plausible, etc.)
- [ ] Logging strategy defined
- [ ] Uptime monitoring configured
### 6. Testing
- [ ] Manual testing completed on staging
- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge)
- [ ] Mobile responsiveness verified
- [ ] Authentication flow tested
- [ ] API error handling tested
### 7. Documentation
- [ ] README.md updated ✓
- [ ] Environment variables documented ✓
- [ ] Deployment instructions clear ✓
- [ ] API documentation available
## Deployment Options
### Option 1: Docker + Cloud Provider (Recommended)
1. Build Docker image:
```bash
docker build -t yaltopia-admin:latest .
```
2. Test locally:
```bash
docker run -p 8080:80 yaltopia-admin:latest
```
3. Push to container registry:
```bash
# For Docker Hub
docker tag yaltopia-admin:latest username/yaltopia-admin:latest
docker push username/yaltopia-admin:latest
# For AWS ECR
aws ecr get-login-password --region region | docker login --username AWS --password-stdin account-id.dkr.ecr.region.amazonaws.com
docker tag yaltopia-admin:latest account-id.dkr.ecr.region.amazonaws.com/yaltopia-admin:latest
docker push account-id.dkr.ecr.region.amazonaws.com/yaltopia-admin:latest
```
4. Deploy to cloud:
- AWS ECS/Fargate
- Google Cloud Run
- Azure Container Instances
- DigitalOcean App Platform
### Option 2: Traditional VPS (Ubuntu/Debian)
1. SSH into your server
2. Install Node.js and nginx:
```bash
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs nginx
```
3. Clone repository:
```bash
git clone <your-repo-url>
cd yaltopia-ticket-admin
```
4. Install dependencies and build:
```bash
npm ci
npm run build:prod
```
5. Configure nginx:
```bash
sudo cp nginx.conf /etc/nginx/sites-available/yaltopia-admin
sudo ln -s /etc/nginx/sites-available/yaltopia-admin /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
6. Copy build files:
```bash
sudo cp -r dist/* /var/www/html/
```
## Post-Deployment
### 1. Verification
- [ ] Application loads correctly
- [ ] All routes work (test deep links)
- [ ] API calls successful
- [ ] Authentication works
- [ ] No console errors
- [ ] Performance acceptable (Lighthouse score)
### 2. Monitoring Setup
- [ ] Error tracking active
- [ ] Analytics tracking
- [ ] Uptime monitoring configured
- [ ] Alert notifications set up
### 3. Backup & Rollback Plan
- [ ] Previous version tagged in git
- [ ] Rollback procedure documented
- [ ] Database backup (if applicable)
## Continuous Deployment
### GitHub Actions (Automated)
The `.github/workflows/ci.yml` file is configured for CI, and `.github/workflows/deploy.yml` builds production artifacts.
For automated deployment, you can extend the workflow to:
1. **Push Docker image to registry:**
```yaml
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_REGISTRY }}/yaltopia-admin:latest
${{ secrets.DOCKER_REGISTRY }}/yaltopia-admin:${{ github.sha }}
```
2. **Deploy to your server via SSH:**
```yaml
- name: Deploy to production server
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /opt/yaltopia-admin
docker pull ${{ secrets.DOCKER_REGISTRY }}/yaltopia-admin:latest
docker-compose down
docker-compose up -d
```
## Troubleshooting
### Build Fails
- Check Node.js version (18+)
- Clear node_modules and reinstall: `rm -rf node_modules package-lock.json && npm install`
- Check for TypeScript errors: `npm run type-check`
### Blank Page After Deploy
- Check browser console for errors
- Verify API URL is correct
- Check nginx/server configuration for SPA routing
- Verify all environment variables are set
### API Calls Failing
- Check CORS configuration on backend
- Verify API URL in environment variables
- Check network tab in browser DevTools
- Verify authentication token handling
### Performance Issues
- Analyze bundle size: `npm run build -- --mode production`
- Check for large dependencies
- Implement code splitting
- Enable compression (gzip/brotli)
- Use CDN for static assets
## Security Hardening
### 1. Content Security Policy (CSP)
Add to nginx.conf or hosting platform headers:
```
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.yourdomain.com;
```
### 2. Additional Security Headers
```
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
```
### 3. Rate Limiting
Implement on backend and consider using Cloudflare or similar CDN with DDoS protection.
## Maintenance
### Regular Tasks
- [ ] Update dependencies monthly: `npm update`
- [ ] Security audit: `npm audit`
- [ ] Review error logs weekly
- [ ] Monitor performance metrics
- [ ] Backup configuration and data
### Updates
1. Test updates in development
2. Deploy to staging
3. Run full test suite
4. Deploy to production during low-traffic period
5. Monitor for issues
## Support
For issues or questions:
- Check logs in error tracking service
- Review browser console errors
- Check server logs
- Contact backend team for API issues

294
dev-docs/DEVELOPMENT.md Normal file
View File

@ -0,0 +1,294 @@
# Development Guide
Complete guide for developing the Yaltopia Ticket Admin application.
## Tech Stack
- **Frontend**: React 18 + TypeScript + Vite
- **UI**: TailwindCSS + shadcn/ui
- **State**: React Query (TanStack Query)
- **Routing**: React Router v6
- **HTTP Client**: Axios
- **Forms**: React Hook Form + Zod
- **Charts**: Recharts
- **Notifications**: Sonner
- **Error Tracking**: Sentry
- **Testing**: Vitest + Testing Library
## Quick Start
```bash
# Install dependencies
npm install
# Set up environment
cp .env.example .env
# Edit .env with your backend URL
# Start development server
npm run dev
# Run tests
npm run test
# Build for production
npm run build
```
## Project Structure
```
src/
├── components/ # Reusable UI components
├── pages/ # Page components
├── services/ # API service layer
│ ├── api/
│ │ └── client.ts # Axios instance
│ ├── auth.service.ts
│ ├── user.service.ts
│ └── ...
├── layouts/ # Layout components
├── lib/ # Utilities
└── test/ # Test utilities
```
## API Architecture
### Service Layer Pattern
All API calls go through typed service classes:
```
Component → Service → API Client → Backend
```
### Available Services
```typescript
import {
authService, // Authentication
userService, // User management
analyticsService, // Analytics
securityService, // Security
systemService, // System health
announcementService,// Announcements
auditService, // Audit logs
settingsService // Settings
} from '@/services'
```
### Usage Examples
**Fetching Data:**
```typescript
import { useQuery } from '@tanstack/react-query'
import { userService } from '@/services'
const { data, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => userService.getUsers({ page: 1, limit: 20 })
})
```
**Mutations:**
```typescript
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { userService } from '@/services'
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (id: string) => userService.deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success('User deleted')
}
})
```
**Direct Calls:**
```typescript
import { authService } from '@/services'
const response = await authService.login({ email, password })
```
## Authentication
### Setup
1. Backend must return tokens on login
2. Frontend stores in httpOnly cookies (recommended) or localStorage
3. All requests automatically include auth token
4. 401 errors trigger automatic token refresh
### Login Flow
```typescript
// User logs in
const response = await authService.login({ email, password })
// Token stored automatically
// User redirected to dashboard
// All subsequent requests include token
await userService.getUsers() // Token added automatically
```
### Protected Routes
```typescript
<Route element={<ProtectedRoute />}>
<Route path="/admin/*" element={<AdminLayout />} />
</Route>
```
### Logout
```typescript
await authService.logout() // Clears tokens & cookies
navigate('/login')
```
## API Standards
### Service Methods
All service methods:
- Return typed data (no `response.data` unwrapping needed)
- Throw errors with `error.response.data.message`
- Use consistent naming (get, create, update, delete)
### Error Handling
```typescript
try {
await userService.deleteUser(id)
toast.success('User deleted')
} catch (error: any) {
toast.error(error.response?.data?.message || 'Operation failed')
}
```
### Type Safety
```typescript
// All responses are typed
const users: PaginatedResponse<User> = await userService.getUsers()
const stats: OverviewStats = await analyticsService.getOverview()
```
## Environment Variables
```bash
# Required
VITE_BACKEND_API_URL=http://localhost:3001/api/v1
# Optional (Sentry)
VITE_SENTRY_DSN=your-sentry-dsn
VITE_SENTRY_ENVIRONMENT=development
```
## Common Tasks
### Adding a New Service Method
```typescript
// src/services/user.service.ts
async exportUserData(userId: string): Promise<Blob> {
const response = await apiClient.get(`/admin/users/${userId}/export`, {
responseType: 'blob'
})
return response.data
}
```
### Adding a New Page
1. Create page component in `src/pages/`
2. Add route in `src/App.tsx`
3. Import required services
4. Use React Query for data fetching
### Adding a New Component
1. Create in `src/components/`
2. Use TypeScript for props
3. Follow existing patterns
4. Add to component exports if reusable
## Best Practices
### React Query
```typescript
// Good - specific query keys
queryKey: ['users', page, limit, search]
// Bad - too generic
queryKey: ['data']
```
### Service Layer
```typescript
// Good - use services
import { userService } from '@/services'
await userService.getUsers()
// Bad - direct axios
import axios from 'axios'
await axios.get('/api/users')
```
### Error Handling
```typescript
// Good - handle errors
try {
await userService.deleteUser(id)
} catch (error: any) {
toast.error(error.response?.data?.message)
}
// Bad - no error handling
await userService.deleteUser(id)
```
### Type Safety
```typescript
// Good - use types
const users: PaginatedResponse<User> = await userService.getUsers()
// Bad - any type
const users: any = await userService.getUsers()
```
## Troubleshooting
### CORS Errors
- Ensure backend has CORS configured
- Check `withCredentials: true` in API client
- Verify `VITE_BACKEND_API_URL` is correct
### 401 Errors
- Check token is being sent
- Verify backend token validation
- Check token expiration
### Build Errors
- Run `npm run build` to check TypeScript errors
- Fix any type errors
- Ensure all imports are correct
### Test Failures
- Run `npm run test` to see failures
- Check mock implementations
- Verify test data matches types
## Additional Resources
- [Testing Guide](./TESTING.md)
- [Deployment Guide](./DEPLOYMENT.md)
- [Security Guide](./SECURITY.md)
- [Troubleshooting](./TROUBLESHOOTING.md)

101
dev-docs/README.md Normal file
View File

@ -0,0 +1,101 @@
# Developer Documentation
Essential documentation for the Yaltopia Ticket Admin project.
## 📚 Documentation
### [Development Guide](./DEVELOPMENT.md)
Complete development guide including:
- Tech stack & project structure
- Quick start & setup
- Common tasks & best practices
- Troubleshooting
### [API & Service Layer Guide](./API_GUIDE.md) ⭐
**Essential reading for making API calls:**
- Service layer architecture
- All available services & methods
- Common patterns & examples
- Error handling
- Best practices
### [Testing Guide](./TESTING_GUIDE.md)
Testing setup and practices:
- Unit testing with Vitest
- Component testing
- Integration testing
- Test utilities & mocks
### [Deployment Guide](./DEPLOYMENT.md)
Production deployment:
- Pre-deployment checklist
- Deployment options (Docker, VPS)
- Environment configuration
- CI/CD setup
### [Security Guide](./SECURITY.md)
Security best practices:
- Authentication & authorization
- Data protection
- Security headers
- CORS configuration
- Input validation
## 🚀 Quick Start
```bash
# Install
npm install
# Configure
cp .env.example .env
# Edit .env with your backend URL
# Develop
npm run dev
# Test
npm run test
# Build
npm run build
```
## 📖 Key Concepts
### Service Layer
All API calls go through typed service classes:
```typescript
import { userService } from '@/services'
const users = await userService.getUsers()
```
### React Query
Data fetching with caching:
```typescript
const { data } = useQuery({
queryKey: ['users'],
queryFn: () => userService.getUsers()
})
```
### Protected Routes
Authentication required for admin routes:
```typescript
<Route element={<ProtectedRoute />}>
<Route path="/admin/*" element={<AdminLayout />} />
</Route>
```
## 🆘 Need Help?
1. **Making API calls?** → [API & Service Layer Guide](./API_GUIDE.md)
2. **General development?** → [Development Guide](./DEVELOPMENT.md)
3. **Writing tests?** → [Testing Guide](./TESTING_GUIDE.md)
4. **Deploying?** → [Deployment Guide](./DEPLOYMENT.md)
5. **Security questions?** → [Security Guide](./SECURITY.md)
---
**Last Updated:** 2024
**Maintained By:** Development Team

339
dev-docs/SECURITY.md Normal file
View File

@ -0,0 +1,339 @@
# Security Guide
## Current Security Implementation
### Authentication
- JWT tokens stored in localStorage
- Automatic token attachment to API requests via Axios interceptor
- Automatic redirect to login on 401 (Unauthorized) responses
- Token removal on logout
### API Security
- HTTPS enforcement (production requirement)
- CORS configuration on backend
- Error handling for common HTTP status codes (401, 403, 404, 500)
- Network error handling
### Client-Side Protection
- Error boundary for graceful error handling
- Input validation on forms
- XSS protection via React's built-in escaping
## Security Improvements for Production
### 1. Token Storage (High Priority)
**Current Issue:** Tokens in localStorage are vulnerable to XSS attacks.
**Recommended Solution:** Use httpOnly cookies
Backend changes needed:
```javascript
// Set cookie on login
res.cookie('access_token', token, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
```
Frontend changes:
```typescript
// Remove localStorage token handling
// Cookies are automatically sent with requests
// Update api-client.ts to remove token interceptor
```
### 2. Content Security Policy (CSP)
Add to your hosting platform or nginx configuration:
```
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' https://api.yourdomain.com;
frame-ancestors 'none';
```
**Note:** Adjust `unsafe-inline` and `unsafe-eval` as you refine your CSP.
### 3. HTTPS Enforcement
**Required for production!**
- Obtain SSL/TLS certificate (Let's Encrypt, Cloudflare, etc.)
- Configure your hosting platform to enforce HTTPS
- Add HSTS header:
```
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
```
### 4. Input Validation & Sanitization
Always validate and sanitize user inputs:
```typescript
// Example: Email validation
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// Example: Sanitize HTML (if displaying user content)
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(dirty);
```
### 5. Rate Limiting
Implement on backend:
- Login attempts: 5 per 15 minutes per IP
- API calls: 100 per minute per user
- Password reset: 3 per hour per email
Consider using:
- express-rate-limit (Node.js)
- Cloudflare rate limiting
- API Gateway rate limiting (AWS, Azure, GCP)
### 6. Dependency Security
Regular security audits:
```bash
# Check for vulnerabilities
npm audit
# Fix automatically (if possible)
npm audit fix
# Update dependencies
npm update
# Check for outdated packages
npm outdated
```
Set up automated dependency updates:
- Dependabot (GitHub)
- Renovate Bot
- Snyk
### 7. Error Handling
**Don't expose sensitive information in errors:**
```typescript
// Bad
catch (error) {
toast.error(error.message); // May expose stack traces
}
// Good
catch (error) {
console.error('Error details:', error); // Log for debugging
toast.error('An error occurred. Please try again.'); // Generic message
}
```
### 8. Secrets Management
**Never commit secrets to git:**
- Use environment variables
- Use secret management services (AWS Secrets Manager, Azure Key Vault, etc.)
- Add `.env*` to `.gitignore`
- Use different secrets for dev/staging/production
### 9. API Key Protection
If using third-party APIs:
```typescript
// Bad - API key in client code
const API_KEY = 'sk_live_123456789';
// Good - Proxy through your backend
const response = await fetch('/api/proxy/third-party-service', {
method: 'POST',
body: JSON.stringify(data)
});
```
### 10. Session Management
Implement proper session handling:
- Session timeout after inactivity (15-30 minutes)
- Logout on browser close (optional)
- Single session per user (optional)
- Session invalidation on password change
```typescript
// Example: Auto-logout on inactivity
let inactivityTimer: NodeJS.Timeout;
const resetInactivityTimer = () => {
clearTimeout(inactivityTimer);
inactivityTimer = setTimeout(() => {
// Logout user
localStorage.removeItem('access_token');
window.location.href = '/login';
}, 30 * 60 * 1000); // 30 minutes
};
// Reset timer on user activity
document.addEventListener('mousemove', resetInactivityTimer);
document.addEventListener('keypress', resetInactivityTimer);
```
## Security Headers Checklist
Configure these headers on your server/hosting platform:
- [x] `X-Frame-Options: SAMEORIGIN`
- [x] `X-Content-Type-Options: nosniff`
- [x] `X-XSS-Protection: 1; mode=block`
- [x] `Referrer-Policy: strict-origin-when-cross-origin`
- [ ] `Strict-Transport-Security: max-age=31536000; includeSubDomains`
- [ ] `Content-Security-Policy: ...`
- [ ] `Permissions-Policy: geolocation=(), microphone=(), camera=()`
## Monitoring & Incident Response
### 1. Error Tracking
Implement error tracking service:
```typescript
// Example: Sentry integration
import * as Sentry from "@sentry/react";
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_ENV,
tracesSampleRate: 1.0,
});
```
### 2. Security Monitoring
Monitor for:
- Failed login attempts
- Unusual API usage patterns
- Error rate spikes
- Slow response times
- Unauthorized access attempts
### 3. Incident Response Plan
1. **Detection:** Monitor logs and alerts
2. **Assessment:** Determine severity and impact
3. **Containment:** Isolate affected systems
4. **Eradication:** Remove threat
5. **Recovery:** Restore normal operations
6. **Lessons Learned:** Document and improve
## Compliance Considerations
### GDPR (if applicable)
- User data encryption
- Right to be forgotten
- Data export functionality
- Privacy policy
- Cookie consent
### HIPAA (if handling health data)
- Additional encryption requirements
- Audit logging
- Access controls
- Business Associate Agreements
### PCI DSS (if handling payments)
- Never store credit card data in frontend
- Use payment gateway (Stripe, PayPal, etc.)
- Secure transmission (HTTPS)
## Security Testing
### Manual Testing
- [ ] Test authentication flows
- [ ] Test authorization (role-based access)
- [ ] Test input validation
- [ ] Test error handling
- [ ] Test session management
### Automated Testing
- [ ] OWASP ZAP scan
- [ ] npm audit
- [ ] Lighthouse security audit
- [ ] SSL Labs test (https://www.ssllabs.com/ssltest/)
### Penetration Testing
Consider hiring security professionals for:
- Vulnerability assessment
- Penetration testing
- Security code review
## Security Checklist for Production
- [ ] HTTPS enabled with valid certificate
- [ ] All security headers configured
- [ ] CSP implemented
- [ ] Tokens stored securely (httpOnly cookies)
- [ ] Input validation on all forms
- [ ] Rate limiting configured
- [ ] Error tracking service active
- [ ] Regular security audits scheduled
- [ ] Dependency updates automated
- [ ] Secrets properly managed
- [ ] Backup and recovery plan in place
- [ ] Incident response plan documented
- [ ] Team trained on security best practices
## Resources
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [MDN Web Security](https://developer.mozilla.org/en-US/docs/Web/Security)
- [React Security Best Practices](https://react.dev/learn/security)
- [Vite Security](https://vitejs.dev/guide/security.html)
## Reporting Security Issues
If you discover a security vulnerability:
1. **Do not** open a public issue
2. Email security@yourdomain.com
3. Include detailed description and steps to reproduce
4. Allow reasonable time for fix before disclosure
## Regular Security Tasks
### Daily
- Monitor error logs
- Check failed login attempts
### Weekly
- Review security alerts
- Check for dependency updates
### Monthly
- Run security audit: `npm audit`
- Update dependencies
- Review access logs
### Quarterly
- Security training for team
- Review and update security policies
- Penetration testing (if budget allows)
### Annually
- Comprehensive security audit
- Update incident response plan
- Review compliance requirements

118
dev-docs/TESTING_GUIDE.md Normal file
View File

@ -0,0 +1,118 @@
# Testing Guide
## Overview
This project uses **Vitest** and **React Testing Library** for testing.
## Running Tests
```bash
# Run tests in watch mode
npm run test
# Run tests once
npm run test:run
# Run tests with UI
npm run test:ui
# Run tests with coverage
npm run test:coverage
```
## Test Structure
```
src/
├── components/
│ ├── __tests__/
│ │ └── ProtectedRoute.test.tsx
│ └── ProtectedRoute.tsx
├── lib/
│ ├── __tests__/
│ │ └── utils.test.ts
│ └── utils.ts
├── pages/
│ └── login/
│ ├── __tests__/
│ │ └── index.test.tsx
│ └── index.tsx
└── test/
├── setup.ts # Test setup
└── test-utils.tsx # Custom render with providers
```
## Writing Tests
### Component Tests
```typescript
import { describe, it, expect } from 'vitest'
import { render, screen } from '@/test/test-utils'
import MyComponent from '../MyComponent'
describe('MyComponent', () => {
it('should render correctly', () => {
render(<MyComponent />)
expect(screen.getByText('Hello')).toBeInTheDocument()
})
})
```
### Testing with User Interactions
```typescript
import userEvent from '@testing-library/user-event'
it('should handle click', async () => {
const user = userEvent.setup()
render(<Button>Click me</Button>)
await user.click(screen.getByRole('button'))
expect(screen.getByText('Clicked')).toBeInTheDocument()
})
```
### Testing API Calls
```typescript
import { vi } from 'vitest'
import { adminApiHelpers } from '@/lib/api-client'
vi.mock('@/lib/api-client', () => ({
adminApiHelpers: {
getUsers: vi.fn(),
},
}))
it('should fetch users', async () => {
const mockGetUsers = vi.mocked(adminApiHelpers.getUsers)
mockGetUsers.mockResolvedValue({ data: [] })
// Test component that calls getUsers
})
```
## Coverage Goals
- **Statements**: 80%+
- **Branches**: 75%+
- **Functions**: 80%+
- **Lines**: 80%+
## Best Practices
1. **Test behavior, not implementation**
2. **Use semantic queries** (getByRole, getByLabelText)
3. **Avoid testing implementation details**
4. **Mock external dependencies**
5. **Keep tests simple and focused**
6. **Use descriptive test names**
## CI Integration
Tests run automatically on:
- Every push to main/develop
- Every pull request
- Before deployment
See `.github/workflows/ci.yml` for details.

View File

@ -3,21 +3,34 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
])
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'warn', // Change from error to warning
},
},
{
files: ['src/test/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
'@typescript-eslint/no-explicit-any': 'off', // Allow any in test files
},
},
)

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/admin-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@ -10,7 +10,7 @@
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>yaltopia-ticket-admin</title>
<title>Yaltopia Ticket Admin</title>
</head>
<body>
<div id="root"></div>

34
nginx.conf Normal file
View File

@ -0,0 +1,34 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Disable access to hidden files
location ~ /\. {
deny all;
}
}

2477
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,20 @@
{
"name": "yaltopia-ticket-admin",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:prod": "tsc -b && vite build --mode production",
"lint": "eslint .",
"preview": "vite preview"
"lint:fix": "eslint . --fix",
"preview": "vite preview",
"type-check": "tsc -b --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
@ -21,8 +28,9 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@sentry/react": "^10.39.0",
"@tanstack/react-query": "^5.90.12",
"axios": "^1.13.2",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@ -36,20 +44,27 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^28.1.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^4.0.18"
}
}

7
public/admin-icon.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="12" fill="#3B82F6"/>
<path d="M32 18C28.6863 18 26 20.6863 26 24C26 27.3137 28.6863 30 32 30C35.3137 30 38 27.3137 38 24C38 20.6863 35.3137 18 32 18Z" fill="white"/>
<path d="M32 32C24.268 32 18 35.582 18 40V44C18 45.1046 18.8954 46 20 46H44C45.1046 46 46 45.1046 46 44V40C46 35.582 39.732 32 32 32Z" fill="white"/>
<circle cx="44" cy="20" r="8" fill="#EF4444"/>
<path d="M44 17V23M41 20H47" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 572 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,13 +1,12 @@
import { Navigate, Route, Routes } from "react-router-dom"
import { AppShell } from "@/layouts/app-shell"
import { ProtectedRoute } from "@/components/ProtectedRoute"
import LoginPage from "@/pages/login"
import DashboardPage from "@/pages/admin/dashboard"
import UsersPage from "@/pages/admin/users"
import UserDetailsPage from "@/pages/admin/users/[id]"
import UserActivityPage from "@/pages/admin/users/[id]/activity"
import LogsPage from "@/pages/admin/logs"
import ErrorLogsPage from "@/pages/admin/logs/errors"
import AccessLogsPage from "@/pages/admin/logs/access"
import LogDetailsPage from "@/pages/admin/logs/[id]"
import ActivityLogPage from "@/pages/activity-log"
import SettingsPage from "@/pages/admin/settings"
import MaintenancePage from "@/pages/admin/maintenance"
import AnnouncementsPage from "@/pages/admin/announcements"
@ -25,20 +24,28 @@ import AnalyticsRevenuePage from "@/pages/admin/analytics/revenue"
import AnalyticsStoragePage from "@/pages/admin/analytics/storage"
import AnalyticsApiPage from "@/pages/admin/analytics/api"
import HealthPage from "@/pages/admin/health"
import NotificationsPage from "@/pages/notifications"
function App() {
return (
<Routes>
<Route element={<AppShell />}>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="admin/dashboard" element={<DashboardPage />} />
<Route path="admin/users" element={<UsersPage />} />
<Route path="admin/users/:id" element={<UserDetailsPage />} />
<Route path="admin/users/:id/activity" element={<UserActivityPage />} />
<Route path="admin/logs" element={<LogsPage />} />
<Route path="admin/logs/errors" element={<ErrorLogsPage />} />
<Route path="admin/logs/access" element={<AccessLogsPage />} />
<Route path="admin/logs/:id" element={<LogDetailsPage />} />
<Route path="admin/logs" element={<ActivityLogPage />} />
<Route path="admin/logs/errors" element={<ActivityLogPage />} />
<Route path="admin/logs/access" element={<ActivityLogPage />} />
<Route path="admin/logs/:id" element={<ActivityLogPage />} />
<Route path="admin/settings" element={<SettingsPage />} />
<Route path="admin/maintenance" element={<MaintenancePage />} />
<Route path="admin/announcements" element={<AnnouncementsPage />} />
@ -56,6 +63,7 @@ function App() {
<Route path="admin/analytics/storage" element={<AnalyticsStoragePage />} />
<Route path="admin/analytics/api" element={<AnalyticsApiPage />} />
<Route path="admin/health" element={<HealthPage />} />
<Route path="notifications" element={<NotificationsPage />} />
</Route>
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
</Routes>

View File

@ -0,0 +1,87 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { AlertCircle } from 'lucide-react';
import { Sentry } from '@/lib/sentry';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
// Log to Sentry
Sentry.captureException(error, {
contexts: {
react: {
componentStack: errorInfo.componentStack,
},
},
});
}
private handleReset = () => {
this.setState({ hasError: false, error: null });
window.location.href = '/';
};
public render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="max-w-md w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="w-5 h-5" />
Something went wrong
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
An unexpected error occurred. The error has been logged and we'll look into it. Please try refreshing the page or contact support if the problem persists.
</p>
{import.meta.env.DEV && this.state.error && (
<div className="p-3 bg-muted rounded-md">
<p className="text-xs font-mono text-destructive break-all">
{this.state.error.message}
</p>
</div>
)}
<div className="flex gap-2">
<Button onClick={this.handleReset} className="flex-1">
Go to Home
</Button>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="flex-1"
>
Refresh Page
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,17 @@
import { Navigate, useLocation } from "react-router-dom"
interface ProtectedRouteProps {
children: React.ReactNode
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const location = useLocation()
const token = localStorage.getItem('access_token')
if (!token) {
// Redirect to login page with return URL
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View File

@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@/test/test-utils'
import { ErrorBoundary } from '../ErrorBoundary'
import { Sentry } from '@/lib/sentry'
// Mock Sentry
vi.mock('@/lib/sentry', () => ({
Sentry: {
captureException: vi.fn(),
},
}))
// Component that throws an error
const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) {
throw new Error('Test error')
}
return <div>No error</div>
}
describe('ErrorBoundary', () => {
beforeEach(() => {
vi.clearAllMocks()
// Suppress console.error for cleaner test output
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('should render children when there is no error', () => {
render(
<ErrorBoundary>
<div>Test Content</div>
</ErrorBoundary>
)
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
it('should render error UI when an error is thrown', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
expect(screen.getByText(/An unexpected error occurred/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Go to Home/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Refresh Page/i })).toBeInTheDocument()
})
it('should log error to Sentry', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
expect(Sentry.captureException).toHaveBeenCalled()
})
it('should show error message in development mode', () => {
const originalEnv = import.meta.env.DEV
import.meta.env.DEV = true
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
expect(screen.getByText('Test error')).toBeInTheDocument()
import.meta.env.DEV = originalEnv
})
it('should handle refresh button click', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
const refreshButton = screen.getByRole('button', { name: /Refresh Page/i })
// Verify button exists and is clickable
expect(refreshButton).toBeInTheDocument()
expect(refreshButton).toBeEnabled()
// Note: We can't easily test window.location.reload() in jsdom
// The button's onClick handler calls window.location.reload() which is tested manually
})
})

View File

@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@/test/test-utils'
import { ProtectedRoute } from '../ProtectedRoute'
describe('ProtectedRoute', () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear()
vi.clearAllMocks()
})
it('should redirect to login when no token exists', () => {
render(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
)
// Should not render protected content
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
})
it('should render children when token exists', () => {
// Set token in localStorage
localStorage.setItem('access_token', 'fake-token')
render(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
)
// Should render protected content
expect(screen.getByText('Protected Content')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,21 @@
import { cva } from "class-variance-authority"
export const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)

View File

@ -1,27 +1,8 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
import { badgeVariants } from "./badge-variants"
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
@ -33,4 +14,4 @@ function Badge({ className, variant, ...props }: BadgeProps) {
)
}
export { Badge, badgeVariants }
export { Badge }

View File

@ -0,0 +1,31 @@
import { cva } from "class-variance-authority"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)

View File

@ -1,38 +1,9 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
import { buttonVariants } from "./button-variants"
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
@ -54,4 +25,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
)
Button.displayName = "Button"
export { Button, buttonVariants }
export { Button }

View File

@ -1,4 +1,5 @@
import { Outlet, Link, useLocation } from "react-router-dom"
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom"
import { useState } from "react"
import {
LayoutDashboard,
Users,
@ -11,16 +12,30 @@ import {
Activity,
Heart,
Search,
Mail,
Bell,
LogOut,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Separator } from "@/components/ui/separator"
import { Card, CardContent } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { authService } from "@/services"
import { toast } from "sonner"
interface User {
email: string
firstName?: string
lastName?: string
role: string
}
const adminNavigationItems = [
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
@ -37,6 +52,22 @@ const adminNavigationItems = [
export function AppShell() {
const location = useLocation()
const navigate = useNavigate()
const [searchQuery, setSearchQuery] = useState("")
// Initialize user from localStorage
const [user] = useState<User | null>(() => {
const userStr = localStorage.getItem('user')
if (userStr) {
try {
return JSON.parse(userStr)
} catch (error) {
console.error('Failed to parse user data:', error)
return null
}
}
return null
})
const isActive = (path: string) => {
return location.pathname.startsWith(path)
@ -50,6 +81,49 @@ export function AppShell() {
return item?.label || "Admin Panel"
}
const handleLogout = async () => {
await authService.logout()
navigate('/login', { replace: true })
}
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
if (searchQuery.trim()) {
const currentPath = location.pathname
navigate(`${currentPath}?search=${encodeURIComponent(searchQuery)}`)
toast.success(`Searching for: ${searchQuery}`)
}
}
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value)
}
const handleNotificationClick = () => {
navigate('/notifications')
}
const handleProfileClick = () => {
navigate('/admin/settings')
}
const getUserInitials = () => {
if (user?.firstName && user?.lastName) {
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
}
if (user?.email) {
return user.email.substring(0, 2).toUpperCase()
}
return 'AD'
}
const getUserDisplayName = () => {
if (user?.firstName && user?.lastName) {
return `${user.firstName} ${user.lastName}`
}
return user?.email || 'Admin User'
}
return (
<div className="flex h-screen bg-background">
{/* Sidebar */}
@ -88,21 +162,18 @@ export function AppShell() {
<div className="p-4 border-t">
<div className="flex items-center gap-3 mb-3">
<Avatar>
<AvatarFallback>AD</AvatarFallback>
<AvatarFallback>{getUserInitials()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">Admin User</p>
<p className="text-xs text-muted-foreground truncate">admin@example.com</p>
<p className="text-sm font-medium truncate">{getUserDisplayName()}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email || 'admin@example.com'}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
localStorage.removeItem('access_token')
window.location.href = '/login'
}}
onClick={handleLogout}
>
<LogOut className="w-4 h-4 mr-2" />
Logout
@ -116,24 +187,50 @@ export function AppShell() {
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
<h1 className="text-2xl font-bold">{getPageTitle()}</h1>
<div className="flex items-center gap-4">
<div className="relative">
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Quick Search..."
className="pl-10 w-64"
value={searchQuery}
onChange={handleSearchChange}
/>
</div>
<Button variant="ghost" size="icon" className="relative">
<Mail className="w-5 h-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
</Button>
<Button variant="ghost" size="icon" className="relative">
</form>
<Button variant="ghost" size="icon" className="relative" onClick={handleNotificationClick}>
<Bell className="w-5 h-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full pointer-events-none" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<Avatar>
<AvatarFallback>AD</AvatarFallback>
<AvatarFallback>{getUserInitials()}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium">{getUserDisplayName()}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleProfileClick}>
<Settings className="w-4 h-4 mr-2" />
Profile Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/notifications')}>
<Bell className="w-4 h-4 mr-2" />
Notifications
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="w-4 h-4 mr-2" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>

View File

@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { cn } from '../utils'
describe('cn utility', () => {
it('should merge class names', () => {
const result = cn('class1', 'class2')
expect(result).toBe('class1 class2')
})
it('should handle conditional classes', () => {
const isConditional = true
const isHidden = false
const result = cn('base', isConditional && 'conditional', isHidden && 'hidden')
expect(result).toBe('base conditional')
})
it('should merge Tailwind classes correctly', () => {
const result = cn('px-2 py-1', 'px-4')
expect(result).toBe('py-1 px-4')
})
it('should handle arrays of classes', () => {
const result = cn(['class1', 'class2'], 'class3')
expect(result).toBe('class1 class2 class3')
})
it('should handle objects with boolean values', () => {
const result = cn({
'class1': true,
'class2': false,
'class3': true,
})
expect(result).toBe('class1 class3')
})
it('should handle undefined and null values', () => {
const result = cn('class1', undefined, null, 'class2')
expect(result).toBe('class1 class2')
})
it('should handle empty input', () => {
const result = cn()
expect(result).toBe('')
})
it('should merge conflicting Tailwind classes', () => {
const result = cn('text-red-500', 'text-blue-500')
expect(result).toBe('text-blue-500')
})
})

View File

@ -1,277 +0,0 @@
import axios, { type AxiosInstance, type AxiosError } from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
// Create axios instance
const adminApi: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add token interceptor
adminApi.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Add response interceptor for error handling
adminApi.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
// Redirect to login
localStorage.removeItem('access_token');
window.location.href = '/login';
} else if (error.response?.status === 403) {
// Show access denied
const message = error.response?.data?.message || 'You do not have permission to access this resource';
if (typeof window !== 'undefined') {
import('sonner').then(({ toast }) => {
toast.error(message);
});
}
} else if (error.response?.status === 404) {
const message = error.response?.data?.message || 'Resource not found';
if (typeof window !== 'undefined') {
import('sonner').then(({ toast }) => {
toast.error(message);
});
}
} else if (error.response?.status === 500) {
const message = error.response?.data?.message || 'Server error occurred. Please try again later.';
if (typeof window !== 'undefined') {
import('sonner').then(({ toast }) => {
toast.error(message);
});
}
} else if (!error.response) {
// Network error
if (typeof window !== 'undefined') {
import('sonner').then(({ toast }) => {
toast.error('Network error. Please check your connection.');
});
}
}
return Promise.reject(error);
}
);
// API helper functions
export const adminApiHelpers = {
// Users
getUsers: (params?: {
page?: number;
limit?: number;
role?: string;
isActive?: boolean;
search?: string;
}) => adminApi.get('/admin/users', { params }),
getUser: (id: string) => adminApi.get(`/admin/users/${id}`),
getUserActivity: (id: string, days: number = 30) =>
adminApi.get(`/admin/users/${id}/activity`, { params: { days } }),
updateUser: (id: string, data: {
role?: string;
isActive?: boolean;
firstName?: string;
lastName?: string;
}) => adminApi.put(`/admin/users/${id}`, data),
deleteUser: (id: string, hard: boolean = false) =>
adminApi.delete(`/admin/users/${id}?hard=${hard}`),
resetPassword: (id: string) =>
adminApi.post(`/admin/users/${id}/reset-password`),
exportUsers: (format: string = 'csv') =>
adminApi.post('/admin/users/export', null, { params: { format } }),
importUsers: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return adminApi.post('/admin/users/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
// Logs
getLogs: (params?: {
page?: number;
limit?: number;
level?: string;
type?: string;
userId?: string;
startDate?: string;
endDate?: string;
search?: string;
minDuration?: number;
}) => adminApi.get('/admin/logs', { params }),
getErrorLogs: (params?: {
page?: number;
limit?: number;
userId?: string;
startDate?: string;
endDate?: string;
}) => adminApi.get('/admin/logs/errors', { params }),
getAccessLogs: (params?: {
page?: number;
limit?: number;
userId?: string;
startDate?: string;
endDate?: string;
}) => adminApi.get('/admin/logs/access', { params }),
getLogById: (id: string) => adminApi.get(`/admin/logs/${id}`),
getLogStats: (startDate?: string, endDate?: string) =>
adminApi.get('/admin/logs/stats/summary', { params: { startDate, endDate } }),
exportLogs: (params: {
format?: string;
level?: string;
startDate?: string;
endDate?: string;
}) => adminApi.post('/admin/logs/export', null, { params }),
cleanupLogs: (days: number = 30) =>
adminApi.post('/admin/logs/cleanup', null, { params: { days } }),
// Analytics
getOverview: () => adminApi.get('/admin/analytics/overview'),
getUserGrowth: (days: number = 30) =>
adminApi.get('/admin/analytics/users/growth', { params: { days } }),
getRevenue: (period: string = '30days') =>
adminApi.get('/admin/analytics/revenue', { params: { period } }),
getStorageAnalytics: () => adminApi.get('/admin/analytics/storage'),
getApiUsage: (days: number = 7) =>
adminApi.get('/admin/analytics/api-usage', { params: { days } }),
getErrorRate: (days: number = 7) =>
adminApi.get('/admin/analytics/error-rate', { params: { days } }),
// System
getHealth: () => adminApi.get('/admin/system/health'),
getSystemInfo: () => adminApi.get('/admin/system/info'),
getSettings: (category?: string) =>
adminApi.get('/admin/system/settings', { params: { category } }),
getSetting: (key: string) => adminApi.get(`/admin/system/settings/${key}`),
createSetting: (data: {
key: string;
value: string;
category: string;
description?: string;
isPublic?: boolean;
}) => adminApi.post('/admin/system/settings', data),
updateSetting: (key: string, data: {
value: string;
description?: string;
isPublic?: boolean;
}) => adminApi.put(`/admin/system/settings/${key}`, data),
deleteSetting: (key: string) => adminApi.delete(`/admin/system/settings/${key}`),
// Maintenance
getMaintenanceStatus: () => adminApi.get('/admin/maintenance'),
enableMaintenance: (message?: string) =>
adminApi.post('/admin/maintenance/enable', { message }),
disableMaintenance: () => adminApi.post('/admin/maintenance/disable'),
// Announcements
getAnnouncements: (activeOnly: boolean = false) =>
adminApi.get('/admin/announcements', { params: { activeOnly } }),
createAnnouncement: (data: {
title: string;
message: string;
type?: string;
priority?: number;
targetAudience?: string;
startsAt?: string;
endsAt?: string;
}) => adminApi.post('/admin/announcements', data),
updateAnnouncement: (id: string, data: {
title?: string;
message?: string;
type?: string;
priority?: number;
targetAudience?: string;
startsAt?: string;
endsAt?: string;
}) => adminApi.put(`/admin/announcements/${id}`, data),
toggleAnnouncement: (id: string) =>
adminApi.patch(`/admin/announcements/${id}/toggle`),
deleteAnnouncement: (id: string) =>
adminApi.delete(`/admin/announcements/${id}`),
// Audit
getAuditLogs: (params?: {
page?: number;
limit?: number;
userId?: string;
action?: string;
resourceType?: string;
resourceId?: string;
startDate?: string;
endDate?: string;
}) => adminApi.get('/admin/audit/logs', { params }),
getUserAuditActivity: (userId: string, days: number = 30) =>
adminApi.get(`/admin/audit/users/${userId}`, { params: { days } }),
getResourceHistory: (type: string, id: string) =>
adminApi.get(`/admin/audit/resource/${type}/${id}`),
getAuditStats: (startDate?: string, endDate?: string) =>
adminApi.get('/admin/audit/stats', { params: { startDate, endDate } }),
// Security
getFailedLogins: (params?: {
page?: number;
limit?: number;
email?: string;
ipAddress?: string;
}) => adminApi.get('/admin/security/failed-logins', { params }),
getSuspiciousActivity: () => adminApi.get('/admin/security/suspicious-activity'),
getAllApiKeys: () => adminApi.get('/admin/security/api-keys'),
revokeApiKey: (id: string) =>
adminApi.patch(`/admin/security/api-keys/${id}/revoke`),
getRateLimitViolations: (days: number = 7) =>
adminApi.get('/admin/security/rate-limits', { params: { days } }),
getActiveSessions: () => adminApi.get('/admin/security/sessions'),
};
export default adminApi;

192
src/lib/error-tracker.ts Normal file
View File

@ -0,0 +1,192 @@
import { Sentry } from './sentry'
import apiClient from '@/services/api/client'
interface ErrorLog {
message: string
stack?: string
url: string
userAgent: string
timestamp: string
userId?: string
extra?: Record<string, unknown>
}
class ErrorTracker {
private queue: ErrorLog[] = []
private isProcessing = false
private maxQueueSize = 50
/**
* Track an error with fallback to backend if Sentry fails
*/
async trackError(
error: Error,
context?: {
tags?: Record<string, string>
extra?: Record<string, unknown>
userId?: string
}
) {
const errorLog: ErrorLog = {
message: error.message,
stack: error.stack,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
userId: context?.userId,
extra: context?.extra,
}
// Try Sentry first
try {
Sentry.captureException(error, {
tags: context?.tags,
extra: context?.extra,
})
} catch (sentryError) {
console.warn('Sentry failed, using fallback:', sentryError)
// If Sentry fails, queue for backend logging
this.queueError(errorLog)
}
// Always log to backend as backup
this.queueError(errorLog)
}
/**
* Track a message with fallback
*/
async trackMessage(
message: string,
level: 'info' | 'warning' | 'error' = 'info',
extra?: Record<string, unknown>
) {
try {
Sentry.captureMessage(message, level)
} catch (sentryError) {
console.warn('Sentry failed, using fallback:', sentryError)
this.queueError({
message,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
extra: { level, ...extra },
})
}
}
/**
* Queue error for backend logging
*/
private queueError(errorLog: ErrorLog) {
this.queue.push(errorLog)
// Prevent queue from growing too large
if (this.queue.length > this.maxQueueSize) {
this.queue.shift()
}
// Process queue
this.processQueue()
}
/**
* Send queued errors to backend
*/
private async processQueue() {
if (this.isProcessing || this.queue.length === 0) {
return
}
this.isProcessing = true
while (this.queue.length > 0) {
const errorLog = this.queue[0]
try {
// Send to your backend error logging endpoint
await apiClient.post('/errors/log', errorLog)
this.queue.shift() // Remove from queue on success
} catch (error) {
console.error('Failed to log error to backend:', error)
// Keep in queue and try again later
break
}
}
this.isProcessing = false
}
/**
* Set user context for tracking
*/
setUser(user: { id: string; email?: string; name?: string }) {
try {
Sentry.setUser({
id: user.id,
email: user.email,
username: user.name,
})
} catch (error) {
console.warn('Failed to set Sentry user:', error)
}
}
/**
* Clear user context (on logout)
*/
clearUser() {
try {
Sentry.setUser(null)
} catch (error) {
console.warn('Failed to clear Sentry user:', error)
}
}
/**
* Add breadcrumb for debugging
*/
addBreadcrumb(
category: string,
message: string,
level: 'info' | 'warning' | 'error' = 'info'
) {
try {
Sentry.addBreadcrumb({
category,
message,
level,
})
} catch (error) {
console.warn('Failed to add Sentry breadcrumb:', error)
}
}
}
// Export singleton instance
export const errorTracker = new ErrorTracker()
// Global error handler
window.addEventListener('error', (event) => {
errorTracker.trackError(new Error(event.message), {
extra: {
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
},
})
})
// Unhandled promise rejection handler
window.addEventListener('unhandledrejection', (event) => {
errorTracker.trackError(
event.reason instanceof Error
? event.reason
: new Error(String(event.reason)),
{
extra: {
type: 'unhandledRejection',
},
}
)
})

46
src/lib/sentry.ts Normal file
View File

@ -0,0 +1,46 @@
import * as Sentry from '@sentry/react'
export const initSentry = () => {
const dsn = import.meta.env.VITE_SENTRY_DSN
const environment = import.meta.env.VITE_ENV || 'development'
// Only initialize Sentry if DSN is provided and not in development
if (dsn && environment !== 'development') {
Sentry.init({
dsn,
environment,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({
maskAllText: true,
blockAllMedia: true,
}),
],
// Performance Monitoring
tracesSampleRate: environment === 'production' ? 0.1 : 1.0,
// Session Replay
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
// Error filtering
beforeSend(event, hint) {
// Filter out errors from browser extensions
if (event.exception) {
const error = hint.originalException
if (error && typeof error === 'object' && 'message' in error) {
const message = String(error.message)
if (
message.includes('chrome-extension://') ||
message.includes('moz-extension://')
) {
return null
}
}
}
return event
},
})
}
}
// Export Sentry for manual error logging
export { Sentry }

View File

@ -6,14 +6,22 @@ import { BrowserRouter } from "react-router-dom"
import { QueryClientProvider } from "@tanstack/react-query"
import { queryClient } from "@/app/query-client"
import { Toaster } from "@/components/ui/toast"
import { ErrorBoundary } from "@/components/ErrorBoundary"
import { initSentry } from "@/lib/sentry"
import "@/lib/error-tracker" // Initialize global error handlers
// Initialize Sentry
initSentry()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster />
</BrowserRouter>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>,
)

View File

@ -1,3 +1,5 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@ -10,14 +12,60 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Search, Download, Eye, MoreVertical } from "lucide-react"
import { Search, Download, Eye, ChevronLeft, ChevronRight } from "lucide-react"
import { auditService, type AuditLog } from "@/services"
import { format } from "date-fns"
export default function ActivityLogPage() {
const [page, setPage] = useState(1)
const [limit] = useState(20)
const [search, setSearch] = useState("")
const [actionFilter, setActionFilter] = useState("")
const [resourceTypeFilter, setResourceTypeFilter] = useState("")
const { data: auditData, isLoading } = useQuery({
queryKey: ['activity-log', page, limit, search, actionFilter, resourceTypeFilter],
queryFn: async () => {
const params: Record<string, string | number> = { page, limit }
if (search) params.search = search
if (actionFilter) params.action = actionFilter
if (resourceTypeFilter) params.resourceType = resourceTypeFilter
return await auditService.getAuditLogs(params)
},
})
const handleExport = async () => {
try {
const blob = await auditService.exportAuditLogs({ format: 'csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `activity-log-${format(new Date(), 'yyyy-MM-dd')}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error('Export failed:', error)
}
}
const getActionBadgeColor = (action: string) => {
const colors: Record<string, string> = {
Create: "bg-blue-500",
Update: "bg-green-500",
Delete: "bg-red-500",
Login: "bg-purple-500",
Logout: "bg-gray-500",
}
return colors[action] || "bg-gray-500"
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold">Activity Log</h2>
<Button>
<Button onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
Export Log
</Button>
@ -33,109 +81,113 @@ export default function ActivityLogPage() {
<Input
placeholder="Search activity..."
className="pl-10 w-64"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select className="px-3 py-2 border rounded-md text-sm">
<option>All Actions</option>
<option>Create</option>
<option>Update</option>
<option>Delete</option>
<option>Login</option>
<option>Logout</option>
<select
className="px-3 py-2 border rounded-md text-sm"
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
>
<option value="">All Actions</option>
<option value="Create">Create</option>
<option value="Update">Update</option>
<option value="Delete">Delete</option>
<option value="Login">Login</option>
<option value="Logout">Logout</option>
</select>
<select className="px-3 py-2 border rounded-md text-sm">
<option>All Users</option>
<option>Admin</option>
<option>Manager</option>
<option>User</option>
<select
className="px-3 py-2 border rounded-md text-sm"
value={resourceTypeFilter}
onChange={(e) => setResourceTypeFilter(e.target.value)}
>
<option value="">All Resources</option>
<option value="Client">Client</option>
<option value="Subscription">Subscription</option>
<option value="User">User</option>
<option value="System">System</option>
</select>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Export
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading activity logs...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Log ID</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Description</TableHead>
<TableHead>Resource</TableHead>
<TableHead>Resource ID</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Timestamp</TableHead>
<TableHead>Action</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">LOG001</TableCell>
<TableCell>john.smith@example.com</TableCell>
{auditData?.data?.map((log: AuditLog) => (
<TableRow key={log.id}>
<TableCell className="font-medium">{log.id}</TableCell>
<TableCell>{log.userId || 'N/A'}</TableCell>
<TableCell>
<Badge className="bg-blue-500">Create</Badge>
<Badge className={getActionBadgeColor(log.action)}>
{log.action}
</Badge>
</TableCell>
<TableCell>{log.resourceType}</TableCell>
<TableCell className="font-mono text-sm">{log.resourceId}</TableCell>
<TableCell>{log.ipAddress || 'N/A'}</TableCell>
<TableCell>
{format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm:ss')}
</TableCell>
<TableCell>Client</TableCell>
<TableCell>Created new client record</TableCell>
<TableCell>192.168.1.1</TableCell>
<TableCell>2024-01-15 10:30:45</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon">
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">LOG002</TableCell>
<TableCell>jane.doe@example.com</TableCell>
<TableCell>
<Badge className="bg-green-500">Update</Badge>
</TableCell>
<TableCell>Subscription</TableCell>
<TableCell>Updated subscription status</TableCell>
<TableCell>192.168.1.2</TableCell>
<TableCell>2024-01-15 09:15:22</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon">
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">LOG003</TableCell>
<TableCell>admin@example.com</TableCell>
<TableCell>
<Badge className="bg-purple-500">Login</Badge>
</TableCell>
<TableCell>System</TableCell>
<TableCell>User logged in successfully</TableCell>
<TableCell>192.168.1.3</TableCell>
<TableCell>2024-01-15 08:00:00</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon">
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{auditData?.data?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No activity logs found
</div>
)}
{auditData && auditData.totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Page {auditData.page} of {auditData.totalPages} ({auditData.total} total)
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(auditData.totalPages, p + 1))}
disabled={page === auditData.totalPages}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>

View File

@ -8,23 +8,18 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { adminApiHelpers } from "@/lib/api-client"
import { analyticsService } from "@/services"
import type { ApiUsageData } from "@/types/analytics.types"
export default function AnalyticsApiPage() {
const { data: apiUsage, isLoading } = useQuery({
queryKey: ['admin', 'analytics', 'api-usage'],
queryFn: async () => {
const response = await adminApiHelpers.getApiUsage(7)
return response.data
},
queryFn: () => analyticsService.getApiUsage(7),
})
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
queryKey: ['admin', 'analytics', 'error-rate'],
queryFn: async () => {
const response = await adminApiHelpers.getErrorRate(7)
return response.data
},
queryFn: () => analyticsService.getErrorRate(7),
})
return (
@ -90,11 +85,11 @@ export default function AnalyticsApiPage() {
</TableRow>
</TableHeader>
<TableBody>
{apiUsage?.map((endpoint: any, index: number) => (
{apiUsage?.map((endpoint: ApiUsageData, index: number) => (
<TableRow key={index}>
<TableCell className="font-mono text-sm">{endpoint.endpoint}</TableCell>
<TableCell>{endpoint.calls}</TableCell>
<TableCell>{endpoint.avgDuration?.toFixed(2) || 'N/A'}</TableCell>
<TableCell className="font-mono text-sm">{endpoint.date}</TableCell>
<TableCell>{endpoint.requests}</TableCell>
<TableCell>{endpoint.avgResponseTime?.toFixed(2) || 'N/A'}</TableCell>
</TableRow>
))}
</TableBody>

View File

@ -1,5 +1,4 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { BarChart3, Users, DollarSign, HardDrive, Activity } from "lucide-react"
import { useNavigate } from "react-router-dom"

View File

@ -1,15 +1,12 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
import { adminApiHelpers } from "@/lib/api-client"
import { analyticsService } from "@/services"
export default function AnalyticsRevenuePage() {
const { data: revenue, isLoading } = useQuery({
queryKey: ['admin', 'analytics', 'revenue'],
queryFn: async () => {
const response = await adminApiHelpers.getRevenue('90days')
return response.data
},
queryFn: () => analyticsService.getRevenue('90days'),
})
return (

View File

@ -1,17 +1,20 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"
import { adminApiHelpers } from "@/lib/api-client"
import { analyticsService } from "@/services"
import type { StorageByUser, StorageAnalytics } from "@/types/analytics.types"
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8']
interface ChartDataItem {
name: string
value: number
}
export default function AnalyticsStoragePage() {
const { data: storage, isLoading } = useQuery({
const { data: storage, isLoading } = useQuery<StorageAnalytics>({
queryKey: ['admin', 'analytics', 'storage'],
queryFn: async () => {
const response = await adminApiHelpers.getStorageAnalytics()
return response.data
},
queryFn: () => analyticsService.getStorageAnalytics(),
})
const formatBytes = (bytes: number) => {
@ -22,7 +25,7 @@ export default function AnalyticsStoragePage() {
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
const chartData = storage?.byCategory?.map((cat: any) => ({
const chartData: ChartDataItem[] = storage?.byCategory?.map((cat) => ({
name: cat.category,
value: cat.size,
})) || []
@ -71,12 +74,12 @@ export default function AnalyticsStoragePage() {
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{chartData.map((entry: any, index: number) => (
{chartData.map((_entry: ChartDataItem, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
@ -100,13 +103,13 @@ export default function AnalyticsStoragePage() {
</CardHeader>
<CardContent>
<div className="space-y-2">
{storage.topUsers.map((user: any, index: number) => (
{storage.topUsers.map((user: StorageByUser, index: number) => (
<div key={index} className="flex items-center justify-between p-2 border rounded">
<div>
<p className="font-medium">{user.user}</p>
<p className="text-sm text-muted-foreground">{user.files} files</p>
<p className="font-medium">{user.userName || user.email}</p>
<p className="text-sm text-muted-foreground">{user.documentCount} files</p>
</div>
<p className="font-medium">{formatBytes(user.size)}</p>
<p className="font-medium">{formatBytes(user.storageUsed)}</p>
</div>
))}
</div>

View File

@ -1,15 +1,12 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
import { adminApiHelpers } from "@/lib/api-client"
import { analyticsService } from "@/services"
export default function AnalyticsUsersPage() {
const { data: userGrowth, isLoading } = useQuery({
queryKey: ['admin', 'analytics', 'users', 'growth'],
queryFn: async () => {
const response = await adminApiHelpers.getUserGrowth(90)
return response.data
},
queryFn: () => analyticsService.getUserGrowth(90),
})
return (

View File

@ -19,41 +19,127 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Megaphone, Plus, Edit, Trash2 } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client"
import { Plus, Edit, Trash2 } from "lucide-react"
import { announcementService, type Announcement, type CreateAnnouncementData } from "@/services"
import { toast } from "sonner"
import { format } from "date-fns"
import type { ApiError } from "@/types/error.types"
export default function AnnouncementsPage() {
const queryClient = useQueryClient()
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null)
const [formDialogOpen, setFormDialogOpen] = useState(false)
const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null)
const [formData, setFormData] = useState<CreateAnnouncementData>({
title: '',
message: '',
type: 'info' as 'info' | 'warning' | 'success' | 'error',
priority: 0,
targetAudience: 'all',
startsAt: '',
endsAt: '',
})
const { data: announcements, isLoading } = useQuery({
queryKey: ['admin', 'announcements'],
queryFn: async () => {
const response = await adminApiHelpers.getAnnouncements(false)
return response.data
queryFn: () => announcementService.getAnnouncements(false),
})
const createMutation = useMutation({
mutationFn: (data: CreateAnnouncementData) => announcementService.createAnnouncement(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
toast.success("Announcement created successfully")
setFormDialogOpen(false)
resetForm()
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to create announcement")
},
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: CreateAnnouncementData }) =>
announcementService.updateAnnouncement(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
toast.success("Announcement updated successfully")
setFormDialogOpen(false)
resetForm()
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to update announcement")
},
})
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await adminApiHelpers.deleteAnnouncement(id)
},
mutationFn: (id: string) => announcementService.deleteAnnouncement(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
toast.success("Announcement deleted successfully")
setDeleteDialogOpen(false)
},
onError: (error: any) => {
toast.error(error.response?.data?.message || "Failed to delete announcement")
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to delete announcement")
},
})
const resetForm = () => {
setFormData({
title: '',
message: '',
type: 'info',
priority: 0,
targetAudience: 'all',
startsAt: '',
endsAt: '',
})
setSelectedAnnouncement(null)
}
const handleOpenCreateDialog = () => {
resetForm()
setFormDialogOpen(true)
}
const handleOpenEditDialog = (announcement: Announcement) => {
setSelectedAnnouncement(announcement)
setFormData({
title: announcement.title || '',
message: announcement.message || '',
type: announcement.type || 'info',
priority: announcement.priority || 0,
targetAudience: announcement.targetAudience || 'all',
startsAt: announcement.startsAt ? announcement.startsAt.split('T')[0] : '',
endsAt: announcement.endsAt ? announcement.endsAt.split('T')[0] : '',
})
setFormDialogOpen(true)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!formData.title || !formData.message) {
toast.error("Title and message are required")
return
}
const submitData = {
...formData,
startsAt: formData.startsAt || undefined,
endsAt: formData.endsAt || undefined,
}
if (selectedAnnouncement) {
updateMutation.mutate({ id: selectedAnnouncement.id, data: submitData })
} else {
createMutation.mutate(submitData)
}
}
const handleDelete = () => {
if (selectedAnnouncement) {
deleteMutation.mutate(selectedAnnouncement.id)
@ -64,7 +150,7 @@ export default function AnnouncementsPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold">Announcements</h2>
<Button onClick={() => setCreateDialogOpen(true)}>
<Button onClick={handleOpenCreateDialog}>
<Plus className="w-4 h-4 mr-2" />
Create Announcement
</Button>
@ -92,7 +178,7 @@ export default function AnnouncementsPage() {
</TableRow>
</TableHeader>
<TableBody>
{announcements?.map((announcement: any) => (
{announcements?.map((announcement: Announcement) => (
<TableRow key={announcement.id}>
<TableCell className="font-medium">{announcement.title}</TableCell>
<TableCell>
@ -112,7 +198,11 @@ export default function AnnouncementsPage() {
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
<Button
variant="ghost"
size="icon"
onClick={() => handleOpenEditDialog(announcement)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
@ -141,6 +231,122 @@ export default function AnnouncementsPage() {
</CardContent>
</Card>
{/* Create/Edit Dialog */}
<Dialog open={formDialogOpen} onOpenChange={setFormDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{selectedAnnouncement ? 'Edit Announcement' : 'Create Announcement'}
</DialogTitle>
<DialogDescription>
{selectedAnnouncement
? 'Update the announcement details below.'
: 'Fill in the details to create a new announcement.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Title *</label>
<input
type="text"
className="w-full px-3 py-2 border rounded-md"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Message *</label>
<textarea
className="w-full px-3 py-2 border rounded-md min-h-[100px]"
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Type</label>
<select
className="w-full px-3 py-2 border rounded-md"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'info' | 'warning' | 'success' | 'error' })}
>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="success">Success</option>
<option value="error">Error</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Priority</label>
<input
type="number"
className="w-full px-3 py-2 border rounded-md"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Target Audience</label>
<input
type="text"
className="w-full px-3 py-2 border rounded-md"
value={formData.targetAudience}
onChange={(e) => setFormData({ ...formData, targetAudience: e.target.value })}
placeholder="all, admins, users, etc."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Start Date</label>
<input
type="date"
className="w-full px-3 py-2 border rounded-md"
value={formData.startsAt}
onChange={(e) => setFormData({ ...formData, startsAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">End Date</label>
<input
type="date"
className="w-full px-3 py-2 border rounded-md"
value={formData.endsAt}
onChange={(e) => setFormData({ ...formData, endsAt: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setFormDialogOpen(false)
resetForm()
}}
>
Cancel
</Button>
<Button
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
>
{selectedAnnouncement ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
@ -163,4 +369,3 @@ export default function AnnouncementsPage() {
</div>
)
}

View File

@ -13,21 +13,20 @@ import {
TableRow,
} from "@/components/ui/table"
import { Search, Eye } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client"
import { auditService, type AuditLog } from "@/services"
import { format } from "date-fns"
export default function AuditPage() {
const [page, setPage] = useState(1)
const [page] = useState(1)
const [limit] = useState(50)
const [search, setSearch] = useState("")
const { data: auditData, isLoading } = useQuery({
queryKey: ['admin', 'audit', 'logs', page, limit, search],
queryFn: async () => {
const params: any = { page, limit }
const params: Record<string, string | number> = { page, limit }
if (search) params.search = search
const response = await adminApiHelpers.getAuditLogs(params)
return response.data
return await auditService.getAuditLogs(params)
},
})
@ -70,7 +69,7 @@ export default function AuditPage() {
</TableRow>
</TableHeader>
<TableBody>
{auditData?.data?.map((log: any) => (
{auditData?.data?.map((log: AuditLog) => (
<TableRow key={log.id}>
<TableCell>
<Badge>{log.action}</Badge>
@ -80,7 +79,7 @@ export default function AuditPage() {
<TableCell>{log.userId || 'N/A'}</TableCell>
<TableCell>{log.ipAddress || 'N/A'}</TableCell>
<TableCell>
{format(new Date(log.createdAt), 'MMM dd, yyyy HH:mm')}
{format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm')}
</TableCell>
<TableCell>
<Button variant="ghost" size="icon">

View File

@ -1,54 +1,72 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Download, Users, FileText, DollarSign, HardDrive, TrendingUp, AlertCircle } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client"
import { Download, Users, FileText, DollarSign, HardDrive, AlertCircle } from "lucide-react"
import { analyticsService, systemService } from "@/services"
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
import { toast } from "sonner"
export default function DashboardPage() {
const { data: overview, isLoading: overviewLoading } = useQuery({
queryKey: ['admin', 'analytics', 'overview'],
queryFn: async () => {
const response = await adminApiHelpers.getOverview()
return response.data
},
queryFn: () => analyticsService.getOverview(),
})
const { data: userGrowth, isLoading: growthLoading } = useQuery({
queryKey: ['admin', 'analytics', 'users', 'growth'],
queryFn: async () => {
const response = await adminApiHelpers.getUserGrowth(30)
return response.data
},
queryFn: () => analyticsService.getUserGrowth(30),
})
const { data: revenue, isLoading: revenueLoading } = useQuery({
queryKey: ['admin', 'analytics', 'revenue'],
queryFn: async () => {
const response = await adminApiHelpers.getRevenue('30days')
return response.data
},
queryFn: () => analyticsService.getRevenue('30days'),
})
const { data: health, isLoading: healthLoading } = useQuery({
queryKey: ['admin', 'system', 'health'],
queryFn: async () => {
const response = await adminApiHelpers.getHealth()
return response.data
},
queryFn: () => systemService.getHealth(),
})
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
queryKey: ['admin', 'analytics', 'error-rate'],
queryFn: async () => {
const response = await adminApiHelpers.getErrorRate(7)
return response.data
},
queryFn: () => analyticsService.getErrorRate(7),
})
const handleExport = () => {
toast.success("Exporting dashboard data...")
try {
// Create CSV content from current dashboard data
const csvContent = [
['Metric', 'Value'],
['Total Users', overview?.users?.total || 0],
['Active Users', overview?.users?.active || 0],
['Inactive Users', overview?.users?.inactive || 0],
['Total Invoices', overview?.invoices?.total || 0],
['Total Revenue', overview?.revenue?.total || 0],
['Storage Used', overview?.storage?.totalSize || 0],
['Total Documents', overview?.storage?.documents || 0],
['Error Rate', errorRate?.errorRate || 0],
['Total Errors', errorRate?.errors || 0],
['Export Date', new Date().toISOString()],
]
.map(row => row.join(','))
.join('\n')
// Create and download the file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `admin-dashboard-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast.success("Dashboard data exported successfully!")
} catch (error) {
toast.error("Failed to export data. Please try again.")
console.error('Export error:', error)
}
}
const formatCurrency = (amount: number) => {

View File

@ -1,28 +1,22 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { AlertCircle, CheckCircle, XCircle, Database, Users, Activity } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client"
import { AlertCircle, CheckCircle, XCircle, Users } from "lucide-react"
import { systemService } from "@/services"
export default function HealthPage() {
const { data: health, isLoading: healthLoading } = useQuery({
queryKey: ['admin', 'system', 'health'],
queryFn: async () => {
const response = await adminApiHelpers.getHealth()
return response.data
},
queryFn: () => systemService.getHealth(),
refetchInterval: 30000, // Refetch every 30 seconds
})
const { data: systemInfo, isLoading: infoLoading } = useQuery({
queryKey: ['admin', 'system', 'info'],
queryFn: async () => {
const response = await adminApiHelpers.getSystemInfo()
return response.data
},
queryFn: () => systemService.getSystemInfo(),
})
const getStatusIcon = (status: string) => {
const getStatusIcon = (status?: string) => {
switch (status?.toLowerCase()) {
case 'healthy':
case 'connected':
@ -142,19 +136,19 @@ export default function HealthPage() {
</div>
<div>
<p className="text-sm text-muted-foreground">Platform</p>
<p className="font-medium">{systemInfo.platform}</p>
<p className="font-medium">{systemInfo.platform || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Architecture</p>
<p className="font-medium">{systemInfo.architecture}</p>
<p className="font-medium">{systemInfo.architecture || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Uptime</p>
<p className="font-medium">{formatUptime(systemInfo.uptime)}</p>
<p className="font-medium">{formatUptime(systemInfo.uptime || 0)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Environment</p>
<p className="font-medium">{systemInfo.env}</p>
<p className="font-medium">{systemInfo.env || systemInfo.environment}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Memory Usage</p>

View File

@ -1,13 +1,13 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Badge } from "@/components/ui/badge"
import { adminApiHelpers } from "@/lib/api-client"
import { systemService } from "@/services"
import { toast } from "sonner"
import { useState } from "react"
import type { ApiError } from "@/types/error.types"
export default function MaintenancePage() {
const queryClient = useQueryClient()
@ -15,36 +15,31 @@ export default function MaintenancePage() {
const { data: status, isLoading } = useQuery({
queryKey: ['admin', 'maintenance'],
queryFn: async () => {
const response = await adminApiHelpers.getMaintenanceStatus()
return response.data
},
queryFn: () => systemService.getMaintenanceStatus(),
})
const enableMutation = useMutation({
mutationFn: async (msg?: string) => {
await adminApiHelpers.enableMaintenance(msg)
},
mutationFn: (msg?: string) => systemService.enableMaintenance(msg),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
toast.success("Maintenance mode enabled")
setMessage("")
},
onError: (error: any) => {
toast.error(error.response?.data?.message || "Failed to enable maintenance mode")
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to enable maintenance mode")
},
})
const disableMutation = useMutation({
mutationFn: async () => {
await adminApiHelpers.disableMaintenance()
},
mutationFn: () => systemService.disableMaintenance(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
toast.success("Maintenance mode disabled")
},
onError: (error: any) => {
toast.error(error.response?.data?.message || "Failed to disable maintenance mode")
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to disable maintenance mode")
},
})
@ -60,6 +55,8 @@ export default function MaintenancePage() {
return <div className="text-center py-8">Loading maintenance status...</div>
}
const isEnabled = status?.status === 'ACTIVE'
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">Maintenance Mode</h2>
@ -68,8 +65,8 @@ export default function MaintenancePage() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Maintenance Status</CardTitle>
<Badge variant={status?.enabled ? 'destructive' : 'default'}>
{status?.enabled ? 'Enabled' : 'Disabled'}
<Badge variant={isEnabled ? 'destructive' : 'default'}>
{isEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</CardHeader>
@ -82,12 +79,12 @@ export default function MaintenancePage() {
</p>
</div>
<Switch
checked={status?.enabled || false}
checked={isEnabled}
onCheckedChange={handleToggle}
/>
</div>
{!status?.enabled && (
{!isEnabled && (
<div className="space-y-2">
<Label htmlFor="message">Maintenance Message (Optional)</Label>
<Input
@ -102,7 +99,7 @@ export default function MaintenancePage() {
</div>
)}
{status?.enabled && status?.message && (
{isEnabled && status?.message && (
<div>
<Label>Current Message</Label>
<p className="text-sm mt-2">{status.message}</p>
@ -113,4 +110,3 @@ export default function MaintenancePage() {
</div>
)
}

View File

@ -10,32 +10,29 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Key, Ban } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client"
import { Ban } from "lucide-react"
import { securityService, type ApiKey } from "@/services"
import { toast } from "sonner"
import { format } from "date-fns"
import type { ApiError } from "@/types/error.types"
export default function ApiKeysPage() {
const queryClient = useQueryClient()
const { data: apiKeys, isLoading } = useQuery({
queryKey: ['admin', 'security', 'api-keys'],
queryFn: async () => {
const response = await adminApiHelpers.getAllApiKeys()
return response.data
},
queryFn: () => securityService.getAllApiKeys(),
})
const revokeMutation = useMutation({
mutationFn: async (id: string) => {
await adminApiHelpers.revokeApiKey(id)
},
mutationFn: (id: string) => securityService.revokeApiKey(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'api-keys'] })
toast.success("API key revoked successfully")
},
onError: (error: any) => {
toast.error(error.response?.data?.message || "Failed to revoke API key")
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to revoke API key")
},
})
@ -63,20 +60,20 @@ export default function ApiKeysPage() {
</TableRow>
</TableHeader>
<TableBody>
{apiKeys?.map((key: any) => (
{apiKeys?.map((key: ApiKey) => (
<TableRow key={key.id}>
<TableCell className="font-medium">{key.name}</TableCell>
<TableCell>{key.userId || 'N/A'}</TableCell>
<TableCell>
{key.lastUsedAt ? format(new Date(key.lastUsedAt), 'MMM dd, yyyy') : 'Never'}
{key.lastUsed ? format(new Date(key.lastUsed), 'MMM dd, yyyy') : 'Never'}
</TableCell>
<TableCell>
<Badge variant={key.revoked ? 'destructive' : 'default'}>
{key.revoked ? 'Revoked' : 'Active'}
<Badge variant={key.isActive ? 'default' : 'destructive'}>
{key.isActive ? 'Active' : 'Revoked'}
</Badge>
</TableCell>
<TableCell>
{!key.revoked && (
{key.isActive && (
<Button
variant="ghost"
size="icon"

View File

@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
@ -12,21 +13,20 @@ import {
TableRow,
} from "@/components/ui/table"
import { Search, Ban } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client"
import { securityService, type FailedLogin } from "@/services"
import { format } from "date-fns"
export default function FailedLoginsPage() {
const [page, setPage] = useState(1)
const [page] = useState(1)
const [limit] = useState(50)
const [search, setSearch] = useState("")
const { data: failedLogins, isLoading } = useQuery({
queryKey: ['admin', 'security', 'failed-logins', page, limit, search],
queryFn: async () => {
const params: any = { page, limit }
const params: Record<string, string | number> = { page, limit }
if (search) params.email = search
const response = await adminApiHelpers.getFailedLogins(params)
return response.data
return await securityService.getFailedLogins(params)
},
})
@ -67,18 +67,18 @@ export default function FailedLoginsPage() {
</TableRow>
</TableHeader>
<TableBody>
{failedLogins?.data?.map((login: any) => (
{failedLogins?.data?.map((login: FailedLogin) => (
<TableRow key={login.id}>
<TableCell className="font-medium">{login.email}</TableCell>
<TableCell className="font-mono text-sm">{login.ipAddress}</TableCell>
<TableCell className="max-w-xs truncate">{login.userAgent}</TableCell>
<TableCell className="max-w-xs truncate">{login.ipAddress}</TableCell>
<TableCell>{login.reason || 'N/A'}</TableCell>
<TableCell>
{format(new Date(login.attemptedAt), 'MMM dd, yyyy HH:mm')}
{format(new Date(login.timestamp), 'MMM dd, yyyy HH:mm')}
</TableCell>
<TableCell>
<Badge variant={login.blocked ? 'destructive' : 'secondary'}>
{login.blocked ? 'Yes' : 'No'}
<Badge variant="secondary">
N/A
</Badge>
</TableCell>
<TableCell>

View File

@ -1,5 +1,4 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Shield, AlertTriangle, Key, Gauge, Users } from "lucide-react"
import { useNavigate } from "react-router-dom"

View File

@ -8,15 +8,13 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { adminApiHelpers } from "@/lib/api-client"
import { securityService } from "@/services"
import type { RateLimitViolation } from "@/types/security.types"
export default function RateLimitsPage() {
const { data: violations, isLoading } = useQuery({
queryKey: ['admin', 'security', 'rate-limits'],
queryFn: async () => {
const response = await adminApiHelpers.getRateLimitViolations(7)
return response.data
},
queryFn: () => securityService.getRateLimitViolations(7),
})
return (
@ -42,7 +40,7 @@ export default function RateLimitsPage() {
</TableRow>
</TableHeader>
<TableBody>
{violations?.map((violation: any) => (
{violations?.map((violation: RateLimitViolation) => (
<TableRow key={violation.id}>
<TableCell>{violation.userId || 'N/A'}</TableCell>
<TableCell className="font-mono text-sm">{violation.ipAddress}</TableCell>

View File

@ -10,16 +10,13 @@ import {
TableRow,
} from "@/components/ui/table"
import { LogOut } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client"
import { securityService, type ActiveSession } from "@/services"
import { format } from "date-fns"
export default function SessionsPage() {
const { data: sessions, isLoading } = useQuery({
queryKey: ['admin', 'security', 'sessions'],
queryFn: async () => {
const response = await adminApiHelpers.getActiveSessions()
return response.data
},
queryFn: () => securityService.getActiveSessions(),
})
return (
@ -46,7 +43,7 @@ export default function SessionsPage() {
</TableRow>
</TableHeader>
<TableBody>
{sessions?.map((session: any) => (
{sessions?.map((session: ActiveSession) => (
<TableRow key={session.id}>
<TableCell className="font-medium">{session.userId || 'N/A'}</TableCell>
<TableCell className="font-mono text-sm">{session.ipAddress}</TableCell>

View File

@ -1,17 +1,14 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Shield, Ban } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client"
import { securityService } from "@/services"
import type { SuspiciousIP, SuspiciousEmail } from "@/types/security.types"
export default function SuspiciousActivityPage() {
const { data: suspicious, isLoading } = useQuery({
queryKey: ['admin', 'security', 'suspicious'],
queryFn: async () => {
const response = await adminApiHelpers.getSuspiciousActivity()
return response.data
},
queryFn: () => securityService.getSuspiciousActivity(),
})
return (
@ -29,9 +26,9 @@ export default function SuspiciousActivityPage() {
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading...</div>
) : suspicious?.suspiciousIPs?.length > 0 ? (
) : (suspicious?.suspiciousIPs?.length ?? 0) > 0 ? (
<div className="space-y-2">
{suspicious.suspiciousIPs.map((ip: any, index: number) => (
{suspicious?.suspiciousIPs?.map((ip: SuspiciousIP, index: number) => (
<div key={index} className="flex items-center justify-between p-2 border rounded">
<div>
<p className="font-mono font-medium">{ip.ipAddress}</p>
@ -62,9 +59,9 @@ export default function SuspiciousActivityPage() {
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading...</div>
) : suspicious?.suspiciousEmails?.length > 0 ? (
) : (suspicious?.suspiciousEmails?.length ?? 0) > 0 ? (
<div className="space-y-2">
{suspicious.suspiciousEmails.map((email: any, index: number) => (
{suspicious?.suspiciousEmails?.map((email: SuspiciousEmail, index: number) => (
<div key={index} className="flex items-center justify-between p-2 border rounded">
<div>
<p className="font-medium">{email.email}</p>

View File

@ -15,8 +15,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Plus } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client"
import { settingsService, type Setting } from "@/services"
import { toast } from "sonner"
import type { ApiError } from "@/types/error.types"
export default function SettingsPage() {
const queryClient = useQueryClient()
@ -31,43 +32,39 @@ export default function SettingsPage() {
const { data: settings, isLoading } = useQuery({
queryKey: ['admin', 'settings', selectedCategory],
queryFn: async () => {
const response = await adminApiHelpers.getSettings(selectedCategory)
return response.data
},
queryFn: () => settingsService.getSettings(selectedCategory),
})
const updateSettingMutation = useMutation({
mutationFn: async ({ key, value }: { key: string; value: string }) => {
await adminApiHelpers.updateSetting(key, { value })
},
mutationFn: ({ key, value }: { key: string; value: string }) =>
settingsService.updateSetting(key, { value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
toast.success("Setting updated successfully")
},
onError: (error: any) => {
toast.error(error.response?.data?.message || "Failed to update setting")
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to update setting")
},
})
const createSettingMutation = useMutation({
mutationFn: async (data: {
mutationFn: (data: {
key: string
value: string
category: string
description?: string
isPublic?: boolean
}) => {
await adminApiHelpers.createSetting(data)
},
}) => settingsService.createSetting(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
toast.success("Setting created successfully")
setCreateDialogOpen(false)
setNewSetting({ key: "", value: "", description: "", isPublic: false })
},
onError: (error: any) => {
toast.error(error.response?.data?.message || "Failed to create setting")
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to create setting")
},
})
@ -118,8 +115,8 @@ export default function SettingsPage() {
{isLoading ? (
<div className="text-center py-8">Loading settings...</div>
) : settings && settings.length > 0 ? (
settings.map((setting: any) => (
<div key={setting.id} className="space-y-2">
settings.map((setting: Setting) => (
<div key={setting.key} className="space-y-2">
<Label htmlFor={setting.key}>{setting.key}</Label>
<div className="flex gap-2">
<Input

View File

@ -3,19 +3,24 @@ import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client"
import { userService } from "@/services"
import { format } from "date-fns"
interface ActivityItem {
action?: string
description?: string
message?: string
createdAt?: string
timestamp?: string
}
export default function UserActivityPage() {
const { id } = useParams()
const navigate = useNavigate()
const { data: activity, isLoading } = useQuery({
queryKey: ['admin', 'users', id, 'activity'],
queryFn: async () => {
const response = await adminApiHelpers.getUserActivity(id!, 30)
return response.data
},
queryFn: () => userService.getUserActivity(id!, 30),
enabled: !!id,
})
@ -39,7 +44,7 @@ export default function UserActivityPage() {
<CardContent>
{activity && activity.length > 0 ? (
<div className="space-y-4">
{activity.map((item: any, index: number) => (
{activity.map((item: ActivityItem, index: number) => (
<div key={index} className="border-l-2 pl-4 pb-4">
<div className="flex items-center justify-between">
<div>
@ -47,7 +52,7 @@ export default function UserActivityPage() {
<p className="text-sm text-muted-foreground">{item.description || item.message}</p>
</div>
<div className="text-sm text-muted-foreground">
{format(new Date(item.createdAt || item.timestamp), 'PPpp')}
{(item.createdAt || item.timestamp) && format(new Date(item.createdAt || item.timestamp!), 'PPpp')}
</div>
</div>
</div>

View File

@ -1,40 +1,78 @@
import { useParams, useNavigate } from "react-router-dom"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ArrowLeft, Edit, Key, Trash2 } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client"
import { toast } from "sonner"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { ArrowLeft, Edit, Key, Loader2 } from "lucide-react"
import { userService } from "@/services"
import { format } from "date-fns"
import { useState } from "react"
import { toast } from "sonner"
export default function UserDetailsPage() {
const { id } = useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [editForm, setEditForm] = useState({
firstName: '',
lastName: '',
email: '',
role: '',
isActive: true,
})
const { data: user, isLoading } = useQuery({
const { data: user, isLoading, refetch } = useQuery({
queryKey: ['admin', 'users', id],
queryFn: async () => {
const response = await adminApiHelpers.getUser(id!)
return response.data
},
queryFn: () => userService.getUser(id!),
enabled: !!id,
})
const updateUserMutation = useMutation({
mutationFn: async (data: any) => {
await adminApiHelpers.updateUser(id!, data)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users', id] })
toast.success("User updated successfully")
},
onError: (error: any) => {
toast.error(error.response?.data?.message || "Failed to update user")
},
const handleEditClick = () => {
if (user) {
setEditForm({
firstName: user.firstName || '',
lastName: user.lastName || '',
email: user.email,
role: user.role,
isActive: user.isActive,
})
setIsEditDialogOpen(true)
}
}
const handleSaveEdit = async () => {
try {
setIsSubmitting(true)
await userService.updateUser(id!, editForm)
toast.success("User updated successfully")
setIsEditDialogOpen(false)
refetch()
} catch (error) {
toast.error("Failed to update user")
console.error('Update error:', error)
} finally {
setIsSubmitting(false)
}
}
if (isLoading) {
return <div className="text-center py-8">Loading user details...</div>
@ -68,7 +106,7 @@ export default function UserDetailsPage() {
<div className="flex items-center justify-between">
<CardTitle>User Information</CardTitle>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" onClick={handleEditClick}>
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
@ -105,7 +143,9 @@ export default function UserDetailsPage() {
</div>
<div>
<p className="text-sm text-muted-foreground">Updated At</p>
<p className="font-medium">{format(new Date(user.updatedAt), 'PPpp')}</p>
<p className="font-medium">
{user.updatedAt ? format(new Date(user.updatedAt), 'PPpp') : 'N/A'}
</p>
</div>
</div>
</CardContent>
@ -149,6 +189,82 @@ export default function UserDetailsPage() {
</div>
</TabsContent>
</Tabs>
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>
Update user information and settings
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
value={editForm.firstName}
onChange={(e) => setEditForm({ ...editForm, firstName: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
value={editForm.lastName}
onChange={(e) => setEditForm({ ...editForm, lastName: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={editForm.role} onValueChange={(value) => setEditForm({ ...editForm, role: value })}>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">Admin</SelectItem>
<SelectItem value="EMPLOYEE">Employee</SelectItem>
<SelectItem value="CLIENT">Client</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={editForm.isActive ? 'active' : 'inactive'}
onValueChange={(value) => setEditForm({ ...editForm, isActive: value === 'active' })}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={handleSaveEdit} disabled={isSubmitting}>
{isSubmitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -29,10 +29,21 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Search, Download, Eye, MoreVertical, UserPlus, Edit, Trash2, Key, Upload } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client"
import { Search, Download, Eye, UserPlus, Trash2, Key, Upload } from "lucide-react"
import { userService } from "@/services"
import { toast } from "sonner"
import { format } from "date-fns"
import type { ApiError } from "@/types/error.types"
interface User {
id: string
email: string
firstName: string
lastName: string
role: string
isActive: boolean
createdAt: string
}
export default function UsersPage() {
const navigate = useNavigate()
@ -42,7 +53,7 @@ export default function UsersPage() {
const [search, setSearch] = useState("")
const [roleFilter, setRoleFilter] = useState<string>("all")
const [statusFilter, setStatusFilter] = useState<string>("all")
const [selectedUser, setSelectedUser] = useState<any>(null)
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
const [importDialogOpen, setImportDialogOpen] = useState(false)
@ -51,74 +62,66 @@ export default function UsersPage() {
const { data: usersData, isLoading } = useQuery({
queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter],
queryFn: async () => {
const params: any = { page, limit }
const params: Record<string, string | number | boolean> = { page, limit }
if (search) params.search = search
if (roleFilter !== 'all') params.role = roleFilter
if (statusFilter !== 'all') params.isActive = statusFilter === 'active'
const response = await adminApiHelpers.getUsers(params)
return response.data
return await userService.getUsers(params)
},
})
const deleteUserMutation = useMutation({
mutationFn: async ({ id, hard }: { id: string; hard: boolean }) => {
await adminApiHelpers.deleteUser(id, hard)
},
mutationFn: ({ id, hard }: { id: string; hard: boolean }) =>
userService.deleteUser(id, hard),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
toast.success("User deleted successfully")
setDeleteDialogOpen(false)
},
onError: (error: any) => {
toast.error(error.response?.data?.message || "Failed to delete user")
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to delete user")
},
})
const resetPasswordMutation = useMutation({
mutationFn: async (id: string) => {
const response = await adminApiHelpers.resetPassword(id)
return response.data
},
mutationFn: (id: string) => userService.resetPassword(id),
onSuccess: (data) => {
toast.success(`Password reset. Temporary password: ${data.temporaryPassword}`)
setResetPasswordDialogOpen(false)
},
onError: (error: any) => {
toast.error(error.response?.data?.message || "Failed to reset password")
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to reset password")
},
})
const importUsersMutation = useMutation({
mutationFn: async (file: File) => {
const response = await adminApiHelpers.importUsers(file)
return response.data
},
mutationFn: (file: File) => userService.importUsers(file),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
toast.success(`Imported ${data.success} users. ${data.failed} failed.`)
toast.success(`Imported ${data.imported} users. ${data.failed} failed.`)
setImportDialogOpen(false)
setImportFile(null)
if (data.errors && data.errors.length > 0) {
console.error('Import errors:', data.errors)
}
},
onError: (error: any) => {
toast.error(error.response?.data?.message || "Failed to import users")
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to import users")
},
})
const handleExport = async () => {
try {
const response = await adminApiHelpers.exportUsers('csv')
const blob = new Blob([response.data], { type: 'text/csv' })
const blob = await userService.exportUsers('csv')
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `users-${new Date().toISOString()}.csv`
a.click()
toast.success("Users exported successfully")
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to export users")
} catch (error) {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to export users")
}
}
@ -235,7 +238,7 @@ export default function UsersPage() {
</TableRow>
</TableHeader>
<TableBody>
{usersData?.data?.map((user: any) => (
{usersData?.data?.map((user: User) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell>{user.firstName} {user.lastName}</TableCell>

View File

@ -1,35 +1,204 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Download } from "lucide-react"
import { Download, FileText, DollarSign, CreditCard, TrendingUp } from "lucide-react"
import { dashboardService } from "@/services"
import { toast } from "sonner"
export default function DashboardPage() {
const { data: profile } = useQuery({
queryKey: ['user', 'profile'],
queryFn: () => dashboardService.getUserProfile(),
})
const { data: stats, isLoading } = useQuery({
queryKey: ['user', 'stats'],
queryFn: () => dashboardService.getUserStats(),
})
const handleExport = () => {
try {
// Create CSV content from current stats
const csvContent = [
['Metric', 'Value'],
['Total Invoices', stats?.totalInvoices || 0],
['Pending Invoices', stats?.pendingInvoices || 0],
['Total Transactions', stats?.totalTransactions || 0],
['Total Revenue', stats?.totalRevenue || 0],
['Growth Percentage', stats?.growthPercentage || 0],
['Export Date', new Date().toISOString()],
]
.map(row => row.join(','))
.join('\n')
// Create and download the file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `dashboard-export-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast.success("Data exported successfully!")
} catch (error) {
toast.error("Failed to export data. Please try again.")
console.error('Export error:', error)
}
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount)
}
const getGreeting = () => {
const hour = new Date().getHours()
if (hour < 12) return 'Good Morning'
if (hour < 18) return 'Good Afternoon'
return 'Good Evening'
}
const userName = profile ? `${profile.firstName} ${profile.lastName}` : 'User'
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold">Good Morning, Admin</h2>
<h2 className="text-3xl font-bold">{getGreeting()}, {userName}</h2>
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
01 Sep - 15 Sep 2024
{new Date().toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</div>
<Button variant="outline">
<Button variant="outline" onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
Export Data
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader>
<CardTitle>Welcome to Dashboard</CardTitle>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Invoices</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
This is your main dashboard page.
{isLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<>
<div className="text-2xl font-bold">{stats?.totalInvoices || 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.pendingInvoices || 0} pending
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Transactions</CardTitle>
<CreditCard className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<>
<div className="text-2xl font-bold">{stats?.totalTransactions || 0}</div>
<p className="text-xs text-muted-foreground">
All time transactions
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<>
<div className="text-2xl font-bold">
{formatCurrency(stats?.totalRevenue || 0)}
</div>
<p className="text-xs text-muted-foreground">
Total earnings
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Growth</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<>
<div className="text-2xl font-bold">
{stats?.growthPercentage !== undefined
? `${stats.growthPercentage > 0 ? '+' : ''}${stats.growthPercentage.toFixed(1)}%`
: 'N/A'
}
</div>
<p className="text-xs text-muted-foreground">
vs last month
</p>
</>
)}
</CardContent>
</Card>
</div>
{stats?.recentActivity && stats.recentActivity.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{stats.recentActivity.map((activity) => (
<div key={activity.id} className="flex items-center justify-between border-b pb-3 last:border-0">
<div>
<p className="font-medium">{activity.description}</p>
<p className="text-sm text-muted-foreground">
{new Date(activity.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
{activity.amount && (
<div className="text-right">
<p className="font-semibold">{formatCurrency(activity.amount)}</p>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,104 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@/test/test-utils'
import userEvent from '@testing-library/user-event'
import LoginPage from '../index'
import { authService } from '@/services'
// Mock the service layer
vi.mock('@/services', () => ({
authService: {
login: vi.fn(),
},
}))
describe('LoginPage', () => {
it('should render login form', () => {
render(<LoginPage />)
expect(screen.getByText('Admin Login')).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
})
it('should show/hide password when eye icon is clicked', async () => {
const user = userEvent.setup()
render(<LoginPage />)
const passwordInput = screen.getByLabelText(/password/i)
expect(passwordInput).toHaveAttribute('type', 'password')
// Click the eye icon to show password
const toggleButton = passwordInput.parentElement?.querySelector('button')
if (toggleButton) {
await user.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'text')
// Click again to hide
await user.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'password')
}
})
it('should handle form submission', async () => {
const user = userEvent.setup()
const mockLogin = vi.mocked(authService.login)
mockLogin.mockResolvedValue({
accessToken: 'fake-token',
refreshToken: 'fake-refresh-token',
user: {
id: '1',
email: 'admin@example.com',
role: 'ADMIN',
firstName: 'Admin',
lastName: 'User',
},
})
render(<LoginPage />)
// Fill in the form
await user.type(screen.getByLabelText(/email/i), 'admin@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
// Submit the form
await user.click(screen.getByRole('button', { name: /login/i }))
// Wait for API call
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith({
email: 'admin@example.com',
password: 'password123',
})
})
})
it('should show error for non-admin users', async () => {
const user = userEvent.setup()
const mockLogin = vi.mocked(authService.login)
mockLogin.mockResolvedValue({
accessToken: 'fake-token',
refreshToken: 'fake-refresh-token',
user: {
id: '1',
email: 'user@example.com',
firstName: 'User',
lastName: 'Test',
role: 'USER', // Not ADMIN
},
})
render(<LoginPage />)
await user.type(screen.getByLabelText(/email/i), 'user@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
// Should show error toast (we'd need to mock sonner for this)
await waitFor(() => {
expect(mockLogin).toHaveBeenCalled()
})
})
})

127
src/pages/login/index.tsx Normal file
View File

@ -0,0 +1,127 @@
import { useState } from "react"
import { useNavigate, useLocation } from "react-router-dom"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Eye, EyeOff } from "lucide-react"
import { toast } from "sonner"
import { authService } from "@/services"
import { errorTracker } from "@/lib/error-tracker"
import type { ApiError, LocationState } from "@/types/error.types"
export default function LoginPage() {
const navigate = useNavigate()
const location = useLocation()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const from = (location.state as LocationState)?.from?.pathname || "/admin/dashboard"
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
const response = await authService.login({ email, password })
// Check if user is admin
if (response.user.role !== 'ADMIN') {
toast.error("Access denied. Admin privileges required.")
setIsLoading(false)
return
}
// Set user context for error tracking
errorTracker.setUser({
id: response.user.id,
email: response.user.email,
name: `${response.user.firstName} ${response.user.lastName}`,
})
// Show success message
toast.success("Login successful!")
// Navigate to dashboard
navigate(from, { replace: true })
} catch (error) {
const apiError = error as ApiError
console.error('Login error:', apiError)
const message = apiError.response?.data?.message || "Invalid email or password"
toast.error(message)
// Track login error
errorTracker.trackError(apiError, {
extra: { email, action: 'login' }
})
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center">
<span className="text-primary-foreground font-bold text-2xl">A</span>
</div>
</div>
<CardTitle className="text-2xl text-center">Admin Login</CardTitle>
<CardDescription className="text-center">
Enter your credentials to access the admin panel
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
disabled={isLoading}
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Logging in..." : "Login"}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -1,3 +1,5 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@ -10,18 +12,162 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Search, Download, Eye, MoreVertical, Bell } from "lucide-react"
import { Search, Download, Eye, CheckCheck, Bell, Loader2 } from "lucide-react"
import { notificationService } from "@/services/notification.service"
import { toast } from "sonner"
export default function NotificationsPage() {
const [searchQuery, setSearchQuery] = useState("")
const [typeFilter, setTypeFilter] = useState("")
const [statusFilter, setStatusFilter] = useState("")
const { data: notifications, isLoading, refetch } = useQuery({
queryKey: ['notifications'],
queryFn: () => notificationService.getNotifications(),
})
const { data: unreadCount } = useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: () => notificationService.getUnreadCount(),
})
// Client-side filtering
const filteredNotifications = useMemo(() => {
if (!notifications) return []
return notifications.filter((notification) => {
// Type filter
if (typeFilter && notification.type !== typeFilter) return false
// Status filter
if (statusFilter === 'read' && !notification.isRead) return false
if (statusFilter === 'unread' && notification.isRead) return false
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
notification.title.toLowerCase().includes(query) ||
notification.message.toLowerCase().includes(query) ||
notification.recipient.toLowerCase().includes(query)
)
}
return true
})
}, [notifications, typeFilter, statusFilter, searchQuery])
const handleExport = () => {
try {
if (!filteredNotifications || filteredNotifications.length === 0) {
toast.error("No notifications to export")
return
}
const csvData = [
['Notification ID', 'Title', 'Message', 'Type', 'Recipient', 'Status', 'Created Date', 'Read Date'],
...filteredNotifications.map(n => [
n.id,
n.title,
n.message,
n.type,
n.recipient,
n.isRead ? 'Read' : 'Unread',
new Date(n.createdAt).toLocaleString(),
n.readAt ? new Date(n.readAt).toLocaleString() : '-'
])
]
const csvContent = csvData.map(row =>
row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')
).join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `notifications-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast.success("Notifications exported successfully!")
} catch (error) {
toast.error("Failed to export notifications")
console.error('Export error:', error)
}
}
const handleMarkAsRead = async (id: string) => {
try {
await notificationService.markAsRead(id)
toast.success("Notification marked as read")
refetch()
} catch (error) {
toast.error("Failed to mark notification as read")
console.error('Mark as read error:', error)
}
}
const handleMarkAllAsRead = async () => {
try {
await notificationService.markAllAsRead()
toast.success("All notifications marked as read")
refetch()
} catch (error) {
toast.error("Failed to mark all as read")
console.error('Mark all as read error:', error)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
const formatDateTime = (dateString?: string) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
const getStatusBadge = (isRead: boolean) => {
return isRead ? 'bg-gray-500' : 'bg-orange-500'
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold">Notifications</h2>
{unreadCount !== undefined && unreadCount > 0 && (
<p className="text-sm text-muted-foreground mt-1">
You have {unreadCount} unread notification{unreadCount !== 1 ? 's' : ''}
</p>
)}
</div>
<div className="flex gap-2">
{unreadCount !== undefined && unreadCount > 0 && (
<Button variant="outline" onClick={handleMarkAllAsRead}>
<CheckCheck className="w-4 h-4 mr-2" />
Mark All as Read
</Button>
)}
<Button>
<Bell className="w-4 h-4 mr-2" />
Create Notification
Settings
</Button>
</div>
</div>
<Card>
<CardHeader>
@ -33,20 +179,32 @@ export default function NotificationsPage() {
<Input
placeholder="Search notification..."
className="pl-10 w-64"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<select className="px-3 py-2 border rounded-md text-sm">
<option>All Types</option>
<option>System</option>
<option>User</option>
<option>Alert</option>
<select
className="px-3 py-2 border rounded-md text-sm"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="">All Types</option>
<option value="system">System</option>
<option value="user">User</option>
<option value="alert">Alert</option>
<option value="invoice">Invoice</option>
<option value="payment">Payment</option>
</select>
<select className="px-3 py-2 border rounded-md text-sm">
<option>All Status</option>
<option>Read</option>
<option>Unread</option>
<select
className="px-3 py-2 border rounded-md text-sm"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">All Status</option>
<option value="read">Read</option>
<option value="unread">Unread</option>
</select>
<Button variant="outline">
<Button variant="outline" onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
@ -54,68 +212,64 @@ export default function NotificationsPage() {
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : filteredNotifications && filteredNotifications.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Notification ID</TableHead>
<TableHead>Title</TableHead>
<TableHead>Message</TableHead>
<TableHead>Type</TableHead>
<TableHead>Recipient</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created Date</TableHead>
<TableHead>Sent Date</TableHead>
<TableHead>Read Date</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">NOT001</TableCell>
<TableCell>System Update Available</TableCell>
{filteredNotifications.map((notification) => (
<TableRow key={notification.id} className={!notification.isRead ? 'bg-blue-50' : ''}>
<TableCell className="font-medium">{notification.id}</TableCell>
<TableCell className="font-medium">{notification.title}</TableCell>
<TableCell className="max-w-xs truncate">{notification.message}</TableCell>
<TableCell>
<Badge variant="outline">System</Badge>
<Badge variant="outline" className="capitalize">{notification.type}</Badge>
</TableCell>
<TableCell>All Users</TableCell>
<TableCell>
<Badge className="bg-blue-500">Sent</Badge>
<Badge className={getStatusBadge(notification.isRead)}>
{notification.isRead ? 'Read' : 'Unread'}
</Badge>
</TableCell>
<TableCell>2024-01-15</TableCell>
<TableCell>2024-01-15 10:00</TableCell>
<TableCell>{formatDate(notification.createdAt)}</TableCell>
<TableCell>{formatDateTime(notification.readAt)}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
{!notification.isRead && (
<Button
variant="ghost"
size="icon"
onClick={() => handleMarkAsRead(notification.id)}
title="Mark as read"
>
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon">
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">NOT002</TableCell>
<TableCell>Payment Received</TableCell>
<TableCell>
<Badge variant="outline">User</Badge>
</TableCell>
<TableCell>john@example.com</TableCell>
<TableCell>
<Badge className="bg-green-500">Delivered</Badge>
</TableCell>
<TableCell>2024-01-14</TableCell>
<TableCell>2024-01-14 14:30</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon">
<MoreVertical className="w-4 h-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground">
{searchQuery || typeFilter || statusFilter
? 'No notifications match your filters'
: 'No notifications found'
}
</div>
)}
</CardContent>
</Card>
</div>

View File

@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { authService } from '../auth.service'
import apiClient from '../api/client'
// Mock the API client
vi.mock('../api/client', () => ({
default: {
post: vi.fn(),
},
}))
describe('AuthService', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
afterEach(() => {
localStorage.clear()
})
describe('login', () => {
it('should login successfully and store tokens', async () => {
const mockResponse = {
data: {
accessToken: 'test-access-token',
refreshToken: 'test-refresh-token',
user: {
id: '1',
email: 'admin@example.com',
firstName: 'Admin',
lastName: 'User',
role: 'ADMIN',
},
},
}
vi.mocked(apiClient.post).mockResolvedValue(mockResponse)
const result = await authService.login({
email: 'admin@example.com',
password: 'password123',
})
expect(apiClient.post).toHaveBeenCalledWith('/auth/login', {
email: 'admin@example.com',
password: 'password123',
})
expect(result).toEqual(mockResponse.data)
expect(localStorage.getItem('access_token')).toBe('test-access-token')
expect(localStorage.getItem('refresh_token')).toBe('test-refresh-token')
expect(localStorage.getItem('user')).toBe(JSON.stringify(mockResponse.data.user))
})
it('should handle login failure', async () => {
vi.mocked(apiClient.post).mockRejectedValue(new Error('Invalid credentials'))
await expect(
authService.login({
email: 'wrong@example.com',
password: 'wrongpassword',
})
).rejects.toThrow('Invalid credentials')
expect(localStorage.getItem('access_token')).toBeNull()
})
})
describe('logout', () => {
it('should logout and clear tokens', async () => {
localStorage.setItem('access_token', 'test-token')
localStorage.setItem('refresh_token', 'test-refresh')
localStorage.setItem('user', JSON.stringify({ id: '1' }))
vi.mocked(apiClient.post).mockResolvedValue({ data: {} })
await authService.logout()
expect(apiClient.post).toHaveBeenCalledWith('/auth/logout')
expect(localStorage.getItem('access_token')).toBeNull()
expect(localStorage.getItem('refresh_token')).toBeNull()
expect(localStorage.getItem('user')).toBeNull()
})
it('should clear tokens even if API call fails', async () => {
localStorage.setItem('access_token', 'test-token')
vi.mocked(apiClient.post).mockRejectedValue(new Error('Network error'))
// The logout method uses try/finally, so it will throw but still clear tokens
try {
await authService.logout()
} catch {
// Error is expected
}
// Tokens should still be cleared due to finally block
expect(localStorage.getItem('access_token')).toBeNull()
})
})
describe('refreshToken', () => {
it('should refresh access token', async () => {
localStorage.setItem('refresh_token', 'old-refresh-token')
const mockResponse = {
data: {
accessToken: 'new-access-token',
},
}
vi.mocked(apiClient.post).mockResolvedValue(mockResponse)
const result = await authService.refreshToken()
expect(apiClient.post).toHaveBeenCalledWith('/auth/refresh', {
refreshToken: 'old-refresh-token',
})
expect(result).toEqual(mockResponse.data)
expect(localStorage.getItem('access_token')).toBe('new-access-token')
})
})
describe('getCurrentUser', () => {
it('should return current user from localStorage', () => {
const user = {
id: '1',
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
role: 'ADMIN',
}
localStorage.setItem('user', JSON.stringify(user))
const result = authService.getCurrentUser()
expect(result).toEqual(user)
})
it('should return null if no user in localStorage', () => {
const result = authService.getCurrentUser()
expect(result).toBeNull()
})
it('should handle invalid JSON in localStorage', () => {
localStorage.setItem('user', 'invalid-json')
expect(() => authService.getCurrentUser()).toThrow()
})
})
describe('isAuthenticated', () => {
it('should return true if access token exists', () => {
localStorage.setItem('access_token', 'test-token')
expect(authService.isAuthenticated()).toBe(true)
})
it('should return false if no access token', () => {
expect(authService.isAuthenticated()).toBe(false)
})
})
describe('isAdmin', () => {
it('should return true if user is admin', () => {
const user = {
id: '1',
email: 'admin@example.com',
firstName: 'Admin',
lastName: 'User',
role: 'ADMIN',
}
localStorage.setItem('user', JSON.stringify(user))
expect(authService.isAdmin()).toBe(true)
})
it('should return false if user is not admin', () => {
const user = {
id: '1',
email: 'user@example.com',
firstName: 'Regular',
lastName: 'User',
role: 'USER',
}
localStorage.setItem('user', JSON.stringify(user))
expect(authService.isAdmin()).toBe(false)
})
it('should return false if no user in localStorage', () => {
expect(authService.isAdmin()).toBe(false)
})
})
})

View File

@ -0,0 +1,118 @@
import apiClient from './api/client'
import type { ApiUsageData, ErrorRateSummary, StorageByUser, StorageAnalytics } from '@/types/analytics.types'
export interface OverviewStats {
users?: {
total: number
active: number
inactive: number
}
invoices?: {
total: number
}
revenue?: {
total: number
}
storage?: {
totalSize: number
documents: number
}
totalUsers: number
activeUsers: number
totalRevenue: number
totalTransactions: number
storageUsed: number
storageLimit: number
}
export interface UserGrowthData {
date: string
users: number
activeUsers: number
}
export interface RevenueData {
date: string
revenue: number
transactions: number
}
class AnalyticsService {
/**
* Get overview statistics
*/
async getOverview(): Promise<OverviewStats> {
const response = await apiClient.get<OverviewStats>('/admin/analytics/overview')
return response.data
}
/**
* Get user growth data
*/
async getUserGrowth(days: number = 30): Promise<UserGrowthData[]> {
const response = await apiClient.get<UserGrowthData[]>('/admin/analytics/users/growth', {
params: { days },
})
return response.data
}
/**
* Get revenue data
*/
async getRevenue(period: '7days' | '30days' | '90days' = '30days'): Promise<RevenueData[]> {
const response = await apiClient.get<RevenueData[]>('/admin/analytics/revenue', {
params: { period },
})
return response.data
}
/**
* Get API usage statistics
*/
async getApiUsage(days: number = 7): Promise<ApiUsageData[]> {
const response = await apiClient.get<ApiUsageData[]>('/admin/analytics/api-usage', {
params: { days },
})
return response.data
}
/**
* Get error rate statistics
*/
async getErrorRate(days: number = 7): Promise<ErrorRateSummary> {
const response = await apiClient.get<ErrorRateSummary>('/admin/analytics/error-rate', {
params: { days },
})
return response.data
}
/**
* Get storage usage by user
*/
async getStorageByUser(limit: number = 10): Promise<StorageByUser[]> {
const response = await apiClient.get<StorageByUser[]>('/admin/analytics/storage/by-user', {
params: { limit },
})
return response.data
}
/**
* Get storage analytics
*/
async getStorageAnalytics(): Promise<StorageAnalytics> {
const response = await apiClient.get<StorageAnalytics>('/admin/analytics/storage')
return response.data
}
/**
* Export analytics data
*/
async exportData(): Promise<Blob> {
const response = await apiClient.get('/admin/analytics/export', {
responseType: 'blob',
})
return response.data
}
}
export const analyticsService = new AnalyticsService()

View File

@ -0,0 +1,88 @@
import apiClient from './api/client'
export interface Announcement {
id: string
title: string
message: string
type: 'info' | 'warning' | 'success' | 'error'
priority: number
targetAudience: string
isActive: boolean
startsAt?: string
endsAt?: string
createdAt: string
updatedAt: string
}
export interface CreateAnnouncementData {
title: string
message: string
type?: 'info' | 'warning' | 'success' | 'error'
priority?: number
targetAudience?: string
startsAt?: string
endsAt?: string
}
export interface UpdateAnnouncementData {
title?: string
message?: string
type?: 'info' | 'warning' | 'success' | 'error'
priority?: number
targetAudience?: string
startsAt?: string
endsAt?: string
}
class AnnouncementService {
/**
* Get all announcements
*/
async getAnnouncements(activeOnly: boolean = false): Promise<Announcement[]> {
const response = await apiClient.get<Announcement[]>('/admin/announcements', {
params: { activeOnly },
})
return response.data
}
/**
* Get single announcement by ID
*/
async getAnnouncement(id: string): Promise<Announcement> {
const response = await apiClient.get<Announcement>(`/admin/announcements/${id}`)
return response.data
}
/**
* Create new announcement
*/
async createAnnouncement(data: CreateAnnouncementData): Promise<Announcement> {
const response = await apiClient.post<Announcement>('/admin/announcements', data)
return response.data
}
/**
* Update announcement
*/
async updateAnnouncement(id: string, data: UpdateAnnouncementData): Promise<Announcement> {
const response = await apiClient.put<Announcement>(`/admin/announcements/${id}`, data)
return response.data
}
/**
* Toggle announcement active status
*/
async toggleAnnouncement(id: string): Promise<Announcement> {
const response = await apiClient.patch<Announcement>(`/admin/announcements/${id}/toggle`)
return response.data
}
/**
* Delete announcement
*/
async deleteAnnouncement(id: string): Promise<void> {
await apiClient.delete(`/admin/announcements/${id}`)
}
}
export const announcementService = new AnnouncementService()

View File

@ -0,0 +1,23 @@
import { describe, it, expect, beforeEach } from 'vitest'
import apiClient from '../client'
describe('API Client', () => {
beforeEach(() => {
localStorage.clear()
})
it('should create axios instance with correct config', () => {
expect(apiClient.defaults.baseURL).toBeDefined()
expect(apiClient.defaults.headers['Content-Type']).toBe('application/json')
expect(apiClient.defaults.withCredentials).toBe(true)
expect(apiClient.defaults.timeout).toBe(30000)
})
it('should have request interceptor configured', () => {
expect(apiClient.interceptors.request.handlers?.length).toBeGreaterThan(0)
})
it('should have response interceptor configured', () => {
expect(apiClient.interceptors.response.handlers?.length).toBeGreaterThan(0)
})
})

View File

@ -0,0 +1,87 @@
import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from 'axios'
const API_BASE_URL = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:3001/api/v1'
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean
}
// Create axios instance with default config
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // Send cookies with requests
timeout: 30000, // 30 second timeout
paramsSerializer: {
serialize: (params) => {
// Custom serializer to preserve number types
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value))
}
})
return searchParams.toString()
}
}
})
// Request interceptor - Add auth token
apiClient.interceptors.request.use(
(config) => {
// Add token from localStorage as fallback (cookies are preferred)
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor - Handle errors and token refresh
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as RetryableAxiosRequestConfig
// Handle 401 Unauthorized - Try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
// Try to refresh token
const refreshToken = localStorage.getItem('refresh_token')
if (refreshToken) {
const response = await axios.post(
`${API_BASE_URL}/auth/refresh`,
{ refreshToken },
{ withCredentials: true }
)
const { accessToken } = response.data
localStorage.setItem('access_token', accessToken)
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return apiClient(originalRequest)
}
} catch (refreshError) {
// Refresh failed - logout user
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
window.location.href = '/login'
return Promise.reject(refreshError)
}
}
return Promise.reject(error)
}
)
export default apiClient

View File

@ -0,0 +1,101 @@
import apiClient from './api/client'
export interface AuditLog {
id: string
userId: string
action: string
resourceType: string
resourceId: string
changes?: Record<string, unknown>
ipAddress: string
userAgent: string
timestamp: string
}
export interface GetAuditLogsParams {
page?: number
limit?: number
userId?: string
action?: string
resourceType?: string
resourceId?: string
startDate?: string
endDate?: string
search?: string
}
export interface AuditStats {
totalActions: number
uniqueUsers: number
topActions: Array<{ action: string; count: number }>
topUsers: Array<{ userId: string; count: number }>
}
class AuditService {
/**
* Get audit logs with pagination and filters
*/
async getAuditLogs(params?: GetAuditLogsParams): Promise<{
data: AuditLog[]
total: number
page: number
limit: number
totalPages: number
}> {
const response = await apiClient.get('/admin/audit/logs', { params })
return response.data
}
/**
* Get audit log by ID
*/
async getAuditLog(id: string): Promise<AuditLog> {
const response = await apiClient.get<AuditLog>(`/admin/audit/logs/${id}`)
return response.data
}
/**
* Get user audit activity
*/
async getUserAuditActivity(userId: string, days: number = 30): Promise<AuditLog[]> {
const response = await apiClient.get<AuditLog[]>(`/admin/audit/users/${userId}`, {
params: { days },
})
return response.data
}
/**
* Get resource history
*/
async getResourceHistory(type: string, id: string): Promise<AuditLog[]> {
const response = await apiClient.get<AuditLog[]>(`/admin/audit/resource/${type}/${id}`)
return response.data
}
/**
* Get audit statistics
*/
async getAuditStats(startDate?: string, endDate?: string): Promise<AuditStats> {
const response = await apiClient.get<AuditStats>('/admin/audit/stats', {
params: { startDate, endDate },
})
return response.data
}
/**
* Export audit logs
*/
async exportAuditLogs(params?: {
format?: 'csv' | 'json'
startDate?: string
endDate?: string
}): Promise<Blob> {
const response = await apiClient.get('/admin/audit/export', {
params,
responseType: 'blob',
})
return response.data
}
}
export const auditService = new AuditService()

View File

@ -0,0 +1,99 @@
import apiClient from './api/client'
export interface LoginRequest {
email: string
password: string
}
export interface LoginResponse {
accessToken: string
refreshToken: string
user: {
id: string
email: string
firstName: string
lastName: string
role: string
}
}
export interface RefreshTokenResponse {
accessToken: string
}
class AuthService {
/**
* Login user with email and password
*/
async login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/auth/login', credentials)
// Store tokens
if (response.data.accessToken) {
localStorage.setItem('access_token', response.data.accessToken)
}
if (response.data.refreshToken) {
localStorage.setItem('refresh_token', response.data.refreshToken)
}
if (response.data.user) {
localStorage.setItem('user', JSON.stringify(response.data.user))
}
return response.data
}
/**
* Logout user
*/
async logout(): Promise<void> {
try {
await apiClient.post('/auth/logout')
} finally {
// Clear local storage even if API call fails
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
}
}
/**
* Refresh access token
*/
async refreshToken(): Promise<RefreshTokenResponse> {
const refreshToken = localStorage.getItem('refresh_token')
const response = await apiClient.post<RefreshTokenResponse>('/auth/refresh', {
refreshToken,
})
if (response.data.accessToken) {
localStorage.setItem('access_token', response.data.accessToken)
}
return response.data
}
/**
* Get current user from localStorage
*/
getCurrentUser() {
const userStr = localStorage.getItem('user')
return userStr ? JSON.parse(userStr) : null
}
/**
* Check if user is authenticated
*/
isAuthenticated(): boolean {
return !!localStorage.getItem('access_token')
}
/**
* Check if user is admin
*/
isAdmin(): boolean {
const user = this.getCurrentUser()
return user?.role === 'ADMIN'
}
}
export const authService = new AuthService()

View File

@ -0,0 +1,59 @@
import apiClient from './api/client'
import type { ActivityLog } from '@/types/activity.types'
export interface UserDashboardStats {
totalInvoices: number
totalTransactions: number
totalRevenue: number
pendingInvoices: number
growthPercentage?: number
recentActivity?: ActivityLog[]
}
export interface UserProfile {
id: string
email: string
firstName: string
lastName: string
role: string
}
class DashboardService {
/**
* Get current user profile
*/
async getUserProfile(): Promise<UserProfile> {
const response = await apiClient.get<UserProfile>('/user/profile')
return response.data
}
/**
* Get user dashboard statistics
*/
async getUserStats(): Promise<UserDashboardStats> {
const response = await apiClient.get<UserDashboardStats>('/user/stats')
return response.data
}
/**
* Get user recent activity
*/
async getRecentActivity(limit: number = 10): Promise<ActivityLog[]> {
const response = await apiClient.get<ActivityLog[]>('/user/activity', {
params: { limit },
})
return response.data
}
/**
* Export user dashboard data
*/
async exportData(): Promise<Blob> {
const response = await apiClient.get('/user/export', {
responseType: 'blob',
})
return response.data
}
}
export const dashboardService = new DashboardService()

23
src/services/index.ts Normal file
View File

@ -0,0 +1,23 @@
// Export all services from a single entry point
export { authService } from './auth.service'
export { userService } from './user.service'
export { analyticsService } from './analytics.service'
export { securityService } from './security.service'
export { systemService } from './system.service'
export { announcementService } from './announcement.service'
export { auditService } from './audit.service'
export { settingsService } from './settings.service'
export { dashboardService } from './dashboard.service'
export { notificationService } from './notification.service'
// Export types
export type { LoginRequest, LoginResponse } from './auth.service'
export type { User, GetUsersParams, PaginatedResponse } from './user.service'
export type { OverviewStats, UserGrowthData, RevenueData } from './analytics.service'
export type { SuspiciousActivity, ActiveSession, FailedLogin, ApiKey } from './security.service'
export type { HealthStatus, SystemInfo, MaintenanceStatus } from './system.service'
export type { Announcement, CreateAnnouncementData, UpdateAnnouncementData } from './announcement.service'
export type { AuditLog, GetAuditLogsParams, AuditStats } from './audit.service'
export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service'
export type { UserDashboardStats, UserProfile } from './dashboard.service'
export type { Notification, NotificationSettings } from './notification.service'

View File

@ -0,0 +1,133 @@
import apiClient from './api/client'
export interface Notification {
id: string
title: string
message: string
type: 'system' | 'user' | 'alert' | 'invoice' | 'payment'
recipient: string
status: 'sent' | 'delivered' | 'read' | 'unread'
isRead: boolean
createdAt: string
sentAt?: string
readAt?: string
}
export interface NotificationSettings {
emailNotifications: boolean
pushNotifications: boolean
invoiceReminders: boolean
paymentAlerts: boolean
systemUpdates: boolean
}
class NotificationService {
/**
* Get all notifications for current user
*/
async getNotifications(params?: {
type?: string
status?: string
search?: string
}): Promise<Notification[]> {
const response = await apiClient.get<Notification[]>('/notifications', {
params,
})
return response.data
}
/**
* Get unread notification count
*/
async getUnreadCount(): Promise<number> {
const response = await apiClient.get<{ count: number }>('/notifications/unread-count')
return response.data.count
}
/**
* Mark notification as read
*/
async markAsRead(id: string): Promise<void> {
await apiClient.post(`/notifications/${id}/read`)
}
/**
* Mark all notifications as read
*/
async markAllAsRead(): Promise<void> {
await apiClient.post('/notifications/read-all')
}
/**
* Send notification (ADMIN only)
*/
async sendNotification(data: {
title: string
message: string
type: string
recipient?: string
recipientType?: 'user' | 'all'
}): Promise<Notification> {
const response = await apiClient.post<Notification>('/notifications/send', data)
return response.data
}
/**
* Subscribe to push notifications
*/
async subscribeToPush(subscription: PushSubscription): Promise<void> {
await apiClient.post('/notifications/subscribe', subscription)
}
/**
* Unsubscribe from push notifications
*/
async unsubscribeFromPush(endpoint: string): Promise<void> {
await apiClient.delete(`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`)
}
/**
* Get notification settings
*/
async getSettings(): Promise<NotificationSettings> {
const response = await apiClient.get<NotificationSettings>('/notifications/settings')
return response.data
}
/**
* Update notification settings
*/
async updateSettings(settings: Partial<NotificationSettings>): Promise<NotificationSettings> {
const response = await apiClient.put<NotificationSettings>('/notifications/settings', settings)
return response.data
}
/**
* Send invoice reminder
*/
async sendInvoiceReminder(invoiceId: string): Promise<void> {
await apiClient.post(`/notifications/invoice/${invoiceId}/reminder`)
}
/**
* Export notifications (creates CSV from current data)
*/
async exportNotifications(notifications: Notification[]): Promise<Blob> {
const csvContent = [
['ID', 'Title', 'Message', 'Type', 'Status', 'Created Date', 'Read Date'],
...notifications.map(n => [
n.id,
n.title,
n.message,
n.type,
n.status,
n.createdAt,
n.readAt || '-'
])
].map(row => row.join(',')).join('\n')
return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
}
}
export const notificationService = new NotificationService()

View File

@ -0,0 +1,116 @@
import apiClient from './api/client'
import type { SuspiciousIP, SuspiciousEmail, RateLimitViolation } from '@/types/security.types'
export interface SuspiciousActivity {
id: string
userId: string
type: string
description: string
ipAddress: string
timestamp: string
severity: 'low' | 'medium' | 'high'
}
export interface ActiveSession {
id: string
userId: string
ipAddress: string
userAgent: string
lastActivity: string
createdAt: string
}
export interface FailedLogin {
id: string
email: string
ipAddress: string
timestamp: string
reason: string
}
export interface ApiKey {
id: string
name: string
key: string
userId: string
createdAt: string
lastUsed?: string
isActive: boolean
}
class SecurityService {
/**
* Get suspicious activity logs
*/
async getSuspiciousActivity(): Promise<{
suspiciousIPs?: SuspiciousIP[]
suspiciousEmails?: SuspiciousEmail[]
}> {
const response = await apiClient.get<{
suspiciousIPs?: SuspiciousIP[]
suspiciousEmails?: SuspiciousEmail[]
}>('/admin/security/suspicious')
return response.data
}
/**
* Get active user sessions
*/
async getActiveSessions(): Promise<ActiveSession[]> {
const response = await apiClient.get<ActiveSession[]>('/admin/security/sessions')
return response.data
}
/**
* Terminate a user session
*/
async terminateSession(sessionId: string): Promise<void> {
await apiClient.delete(`/admin/security/sessions/${sessionId}`)
}
/**
* Get failed login attempts
*/
async getFailedLogins(params?: {
page?: number
limit?: number
email?: string
}): Promise<{ data: FailedLogin[]; total: number }> {
const response = await apiClient.get<{ data: FailedLogin[]; total: number }>('/admin/security/failed-logins', { params })
return response.data
}
/**
* Get rate limit violations
*/
async getRateLimitViolations(days: number = 7): Promise<RateLimitViolation[]> {
const response = await apiClient.get<RateLimitViolation[]>('/admin/security/rate-limits', {
params: { days },
})
return response.data
}
/**
* Get all API keys
*/
async getAllApiKeys(): Promise<ApiKey[]> {
const response = await apiClient.get<ApiKey[]>('/admin/security/api-keys')
return response.data
}
/**
* Revoke an API key
*/
async revokeApiKey(id: string): Promise<void> {
await apiClient.delete(`/admin/security/api-keys/${id}`)
}
/**
* Ban an IP address
*/
async banIpAddress(ipAddress: string, reason: string): Promise<void> {
await apiClient.post('/admin/security/ban-ip', { ipAddress, reason })
}
}
export const securityService = new SecurityService()

View File

@ -0,0 +1,78 @@
import apiClient from './api/client'
export interface Setting {
key: string
value: string
category: string
description?: string
isPublic: boolean
createdAt: string
updatedAt: string
}
export interface CreateSettingData {
key: string
value: string
category: string
description?: string
isPublic?: boolean
}
export interface UpdateSettingData {
value: string
description?: string
isPublic?: boolean
}
class SettingsService {
/**
* Get all settings, optionally filtered by category
*/
async getSettings(category?: string): Promise<Setting[]> {
const response = await apiClient.get<Setting[]>('/admin/system/settings', {
params: { category },
})
return response.data
}
/**
* Get single setting by key
*/
async getSetting(key: string): Promise<Setting> {
const response = await apiClient.get<Setting>(`/admin/system/settings/${key}`)
return response.data
}
/**
* Create new setting
*/
async createSetting(data: CreateSettingData): Promise<Setting> {
const response = await apiClient.post<Setting>('/admin/system/settings', data)
return response.data
}
/**
* Update setting
*/
async updateSetting(key: string, data: UpdateSettingData): Promise<Setting> {
const response = await apiClient.put<Setting>(`/admin/system/settings/${key}`, data)
return response.data
}
/**
* Delete setting
*/
async deleteSetting(key: string): Promise<void> {
await apiClient.delete(`/admin/system/settings/${key}`)
}
/**
* Get public settings (for frontend use)
*/
async getPublicSettings(): Promise<Record<string, string>> {
const response = await apiClient.get<Record<string, string>>('/settings/public')
return response.data
}
}
export const settingsService = new SettingsService()

View File

@ -0,0 +1,99 @@
import apiClient from './api/client'
export interface HealthStatus {
status: 'healthy' | 'degraded' | 'down'
database: 'connected' | 'disconnected'
redis: 'connected' | 'disconnected'
uptime: number
timestamp: string
recentErrors?: number
activeUsers?: number
}
export interface SystemInfo {
version: string
environment: string
nodeVersion: string
memory: {
used: number
total: number
}
cpu: {
usage: number
cores: number
loadAverage?: number[]
}
platform?: string
architecture?: string
uptime?: number
env?: string
}
export interface MaintenanceStatus {
id: string
status: 'ACTIVE' | 'INACTIVE'
message?: string
scheduledAt?: string | null
startedAt?: string | null
endedAt?: string | null
enabledBy?: string
createdAt: string
updatedAt: string
}
class SystemService {
/**
* Get system health status
*/
async getHealth(): Promise<HealthStatus> {
const response = await apiClient.get<HealthStatus>('/admin/system/health')
return response.data
}
/**
* Get system information
*/
async getSystemInfo(): Promise<SystemInfo> {
const response = await apiClient.get<SystemInfo>('/admin/system/info')
return response.data
}
/**
* Get maintenance mode status
*/
async getMaintenanceStatus(): Promise<MaintenanceStatus> {
const response = await apiClient.get<MaintenanceStatus>('/admin/maintenance')
return response.data
}
/**
* Enable maintenance mode
*/
async enableMaintenance(message?: string): Promise<void> {
await apiClient.post('/admin/maintenance/enable', { message })
}
/**
* Disable maintenance mode
*/
async disableMaintenance(): Promise<void> {
await apiClient.post('/admin/maintenance/disable')
}
/**
* Clear application cache
*/
async clearCache(): Promise<void> {
await apiClient.post('/admin/system/clear-cache')
}
/**
* Run database migrations
*/
async runMigrations(): Promise<{ success: boolean; message: string }> {
const response = await apiClient.post('/admin/system/migrate')
return response.data
}
}
export const systemService = new SystemService()

View File

@ -0,0 +1,140 @@
import apiClient from './api/client'
import type { UserActivity } from '@/types/activity.types'
export interface User {
id: string
email: string
firstName: string
lastName: string
role: string
isActive: boolean
createdAt: string
updatedAt?: string
lastLogin?: string
_count?: {
invoices?: number
transactions?: number
documents?: number
activityLogs?: number
reports?: number
payments?: number
}
}
export interface GetUsersParams {
page?: number
limit?: number
search?: string
role?: string
isActive?: boolean
}
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
limit: number
totalPages: number
}
class UserService {
/**
* Get paginated list of users
*/
async getUsers(params?: GetUsersParams): Promise<PaginatedResponse<User>> {
// Ensure numeric params are sent as numbers, not strings
const queryParams = params ? {
...params,
page: params.page ? Number(params.page) : undefined,
limit: params.limit ? Number(params.limit) : undefined,
} : undefined
const response = await apiClient.get<PaginatedResponse<User>>('/admin/users', { params: queryParams })
return response.data
}
/**
* Get single user by ID
*/
async getUser(id: string): Promise<User> {
const response = await apiClient.get<User>(`/admin/users/${id}`)
return response.data
}
/**
* Create new user
*/
async createUser(data: Partial<User>): Promise<User> {
const response = await apiClient.post<User>('/admin/users', data)
return response.data
}
/**
* Update user
*/
async updateUser(id: string, data: Partial<User>): Promise<User> {
const response = await apiClient.patch<User>(`/admin/users/${id}`, data)
return response.data
}
/**
* Delete user (soft or hard delete)
*/
async deleteUser(id: string, hard: boolean = false): Promise<void> {
await apiClient.delete(`/admin/users/${id}`, {
params: { hard },
})
}
/**
* Reset user password
*/
async resetPassword(id: string): Promise<{ temporaryPassword: string }> {
const response = await apiClient.post<{ temporaryPassword: string }>(
`/admin/users/${id}/reset-password`
)
return response.data
}
/**
* Get user activity logs
*/
async getUserActivity(id: string, days: number = 30): Promise<UserActivity[]> {
const response = await apiClient.get<UserActivity[]>(`/admin/users/${id}/activity`, {
params: { days },
})
return response.data
}
/**
* Import users from CSV
*/
async importUsers(file: File): Promise<{ imported: number; failed: number }> {
const formData = new FormData()
formData.append('file', file)
const response = await apiClient.post<{ imported: number; failed: number }>(
'/admin/users/import',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data
}
/**
* Export users to CSV
*/
async exportUsers(format: 'csv' | 'json' = 'csv'): Promise<Blob> {
const response = await apiClient.get('/admin/users/export', {
params: { format },
responseType: 'blob',
})
return response.data
}
}
export const userService = new UserService()

62
src/test/setup.ts Normal file
View File

@ -0,0 +1,62 @@
import { expect, afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
import React from 'react'
// Mock react-router-dom before any imports
vi.mock('react-router-dom', () => ({
BrowserRouter: ({ children }: { children: React.ReactNode }) => children,
MemoryRouter: ({ children }: { children: React.ReactNode }) => children,
Routes: ({ children }: { children: React.ReactNode }) => children,
Route: () => null,
Link: ({ children, to }: { children: React.ReactNode; to: string }) =>
React.createElement('a', { href: to }, children),
NavLink: ({ children, to }: { children: React.ReactNode; to: string }) =>
React.createElement('a', { href: to }, children),
Navigate: ({ to }: { to: string }) => `Navigate to ${to}`,
Outlet: () => null,
useNavigate: () => vi.fn(),
useLocation: () => ({ pathname: '/', search: '', hash: '', state: null, key: 'default' }),
useParams: () => ({}),
useSearchParams: () => [new URLSearchParams(), vi.fn()],
useMatch: () => null,
useResolvedPath: (to: string) => ({ pathname: to, search: '', hash: '' }),
useHref: (to: string) => to,
useOutlet: () => null,
useOutletContext: () => ({}),
}))
// Extend Vitest's expect with jest-dom matchers
expect.extend(matchers)
// Cleanup after each test
afterEach(() => {
cleanup()
})
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {}, // deprecated
removeListener: () => {}, // deprecated
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => {},
}),
})
// Mock IntersectionObserver
globalThis.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords() {
return []
}
unobserve() {}
} as any

41
src/test/test-utils.tsx Normal file
View File

@ -0,0 +1,41 @@
import type { ReactElement } from 'react'
import { render, type RenderOptions } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
// Use the mocked MemoryRouter from our mock
const MemoryRouter = ({ children }: { children: React.ReactNode }) => <>{children}</>
// Create a custom render function that includes providers
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
interface AllTheProvidersProps {
children: React.ReactNode
}
const AllTheProviders = ({ children }: AllTheProvidersProps) => {
const testQueryClient = createTestQueryClient()
return (
<QueryClientProvider client={testQueryClient}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
)
}
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: AllTheProviders, ...options })
// Export everything from @testing-library/react
export { screen, waitFor, within, fireEvent } from '@testing-library/react'
// Export our custom render
export { customRender as render }

9
src/test/vitest.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'
import 'vitest'
declare module 'vitest' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface Assertion<T = unknown> extends TestingLibraryMatchers<typeof expect.stringContaining, T> {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface AsymmetricMatchersContaining extends TestingLibraryMatchers<ReturnType<typeof expect.stringContaining>, unknown> {}
}

View File

@ -0,0 +1,18 @@
export interface ActivityLog {
id: string
type: string
description: string
date: string
amount?: number
userId?: string
metadata?: Record<string, unknown>
}
export interface UserActivity extends ActivityLog {
userId: string
action: string
resourceType?: string
resourceId?: string
ipAddress?: string
userAgent?: string
}

View File

@ -0,0 +1,46 @@
import type { StorageCategory } from './common.types'
export interface ApiUsageData {
date: string
requests: number
errors: number
avgResponseTime: number
}
export interface ErrorRateData {
date: string
errorRate: number
totalErrors: number
totalRequests: number
}
export interface ErrorRateSummary {
errorRate: number
errors: number
total: number
}
export interface StorageByUser {
userId: string
userName: string
email: string
storageUsed: number
documentCount: number
}
export interface StorageAnalytics {
totalStorage: number
usedStorage: number
availableStorage: number
byCategory?: StorageCategory[]
total?: {
size: number
files: number
}
storageByType: Array<{
type: string
size: number
count: number
}>
topUsers: StorageByUser[]
}

63
src/types/common.types.ts Normal file
View File

@ -0,0 +1,63 @@
// Common types used across the application
export interface ApiError {
response?: {
data?: {
message?: string
}
}
message?: string
}
export interface QueryParams {
page?: number
limit?: number
search?: string
[key: string]: string | number | boolean | undefined
}
export interface Announcement {
id: string
title: string
content: string
type: 'info' | 'warning' | 'success' | 'error'
isActive: boolean
createdAt: string
updatedAt?: string
}
export interface Setting {
id: string
key: string
value: string
description?: string
isPublic: boolean
createdAt: string
updatedAt?: string
}
export interface ApiEndpointUsage {
endpoint: string
requests: number
avgResponseTime: number
errorRate: number
}
export interface StorageCategory {
category: string
size: number
count: number
}
export interface StorageData {
totalStorage: number
usedStorage: number
availableStorage: number
byCategory?: StorageCategory[]
topUsers: Array<{
userId: string
userName: string
email: string
storageUsed: number
}>
}

15
src/types/error.types.ts Normal file
View File

@ -0,0 +1,15 @@
import type { AxiosError } from 'axios'
export interface ApiErrorResponse {
message: string
statusCode?: number
error?: string
}
export type ApiError = AxiosError<ApiErrorResponse>
export interface LocationState {
from?: {
pathname: string
}
}

View File

@ -0,0 +1,66 @@
export interface SuspiciousIP {
ipAddress: string
attempts: number
lastAttempt: string
severity: 'low' | 'medium' | 'high'
}
export interface SuspiciousEmail {
email: string
attempts: number
lastAttempt: string
severity: 'low' | 'medium' | 'high'
}
export interface RateLimitViolation {
id: string
ipAddress: string
endpoint: string
attempts: number
requests: number
period: string
timestamp: string
userId?: string
}
export interface Session {
id: string
userId: string
ipAddress: string
userAgent: string
createdAt: string
expiresAt: string
}
export interface RateViolation {
id: string
userId?: string
ipAddress: string
endpoint: string
attempts: number
requests: number
period: string
timestamp: string
}
export interface FailedLogin {
id: string
email: string
ipAddress: string
userAgent: string
reason?: string
attemptedAt: string
blocked: boolean
}
export interface ApiKey {
id: string
name: string
key: string
userId: string
createdAt: string
expiresAt?: string
lastUsedAt?: string
revoked?: boolean
isActive: boolean
}

View File

@ -10,4 +10,28 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
build: {
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'ui-vendor': ['@radix-ui/react-avatar', '@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', '@radix-ui/react-select'],
'chart-vendor': ['recharts'],
'query-vendor': ['@tanstack/react-query'],
},
},
},
chunkSizeWarningLimit: 1000,
},
server: {
port: 5173,
strictPort: false,
host: true,
},
preview: {
port: 4173,
strictPort: false,
host: true,
},
})

36
vitest.config.ts Normal file
View File

@ -0,0 +1,36 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'node:path'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
pool: 'vmThreads',
testTimeout: 10000,
hookTimeout: 10000,
deps: {
inline: [/react-router/, /react-router-dom/],
},
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/mockData',
'dist/',
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})