Compare commits
9 Commits
20b0251259
...
9c7e33499a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7e33499a | ||
|
|
64ba7cfc31 | ||
|
|
7b0b5099fe | ||
|
|
d251958a9b | ||
|
|
83743343c9 | ||
|
|
ba209593f5 | ||
|
|
a1c9b689d5 | ||
|
|
6021d83385 | ||
|
|
375d75fe44 |
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
11
.env.example
Normal 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
11
.env.production.example
Normal 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
85
.github/workflows/ci.yml
vendored
Normal 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 }}
|
||||
67
.github/workflows/deploy.yml
vendored
Normal file
67
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to Netlify/Vercel
|
||||
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 }}
|
||||
|
||||
# Option 1: Deploy to Netlify
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@v3.0
|
||||
with:
|
||||
publish-dir: './dist'
|
||||
production-branch: main
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
enable-pull-request-comment: true
|
||||
enable-commit-comment: true
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
timeout-minutes: 5
|
||||
|
||||
# Option 2: Deploy to Vercel (comment out Netlify if using this)
|
||||
# - name: Deploy to Vercel
|
||||
# uses: amondnet/vercel-action@v25
|
||||
# with:
|
||||
# vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
# vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
|
||||
# vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
# vercel-args: '--prod'
|
||||
# working-directory: ./
|
||||
|
||||
- name: Notify deployment success
|
||||
if: success()
|
||||
run: echo "Deployment successful!"
|
||||
|
||||
- name: Notify deployment failure
|
||||
if: failure()
|
||||
run: echo "Deployment failed!"
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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
35
Dockerfile
Normal 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;"]
|
||||
298
README.md
298
README.md
|
|
@ -1,73 +1,253 @@
|
|||
# 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
|
||||
|
||||
### Static Hosting (Netlify, Vercel, etc.)
|
||||
|
||||
1. Build the application: `npm run build:prod`
|
||||
2. Deploy the `dist` directory
|
||||
3. Configure environment variables in your hosting platform
|
||||
4. Set up redirects for SPA routing (see below)
|
||||
|
||||
### SPA Routing Configuration
|
||||
|
||||
For proper routing, add a redirect rule:
|
||||
|
||||
**Netlify** (`netlify.toml`):
|
||||
```toml
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
```
|
||||
|
||||
**Vercel** (`vercel.json`):
|
||||
```json
|
||||
{
|
||||
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
|
||||
}
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
Create a `Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine as build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build:prod
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
Create `nginx.conf`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
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
983
dev-docs/API_GUIDE.md
Normal 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
|
||||
279
dev-docs/DEPLOYMENT.md
Normal file
279
dev-docs/DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
# 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: Vercel (Recommended for Quick Deploy)
|
||||
|
||||
1. Install Vercel CLI:
|
||||
```bash
|
||||
npm i -g vercel
|
||||
```
|
||||
|
||||
2. Login to Vercel:
|
||||
```bash
|
||||
vercel login
|
||||
```
|
||||
|
||||
3. Deploy:
|
||||
```bash
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
4. Set environment variables in Vercel dashboard:
|
||||
- Go to Project Settings → Environment Variables
|
||||
- Add `VITE_API_URL` with your production API URL
|
||||
|
||||
### Option 2: Netlify
|
||||
|
||||
1. Install Netlify CLI:
|
||||
```bash
|
||||
npm i -g netlify-cli
|
||||
```
|
||||
|
||||
2. Login:
|
||||
```bash
|
||||
netlify login
|
||||
```
|
||||
|
||||
3. Deploy:
|
||||
```bash
|
||||
netlify deploy --prod
|
||||
```
|
||||
|
||||
4. Set environment variables in Netlify dashboard
|
||||
|
||||
### Option 3: Docker + Cloud Provider
|
||||
|
||||
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 4: 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.
|
||||
|
||||
For CD, add deployment step:
|
||||
|
||||
```yaml
|
||||
- name: Deploy to Vercel
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
npm i -g vercel
|
||||
vercel --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||
```
|
||||
|
||||
Or for Netlify:
|
||||
|
||||
```yaml
|
||||
- name: Deploy to Netlify
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
npm i -g netlify-cli
|
||||
netlify deploy --prod --auth=${{ secrets.NETLIFY_AUTH_TOKEN }} --site=${{ secrets.NETLIFY_SITE_ID }}
|
||||
```
|
||||
|
||||
## 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
294
dev-docs/DEVELOPMENT.md
Normal 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
101
dev-docs/README.md
Normal 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 (Vercel, Netlify, Docker)
|
||||
- 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
339
dev-docs/SECURITY.md
Normal 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
118
dev-docs/TESTING_GUIDE.md
Normal 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.
|
||||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
24
netlify.toml
Normal file
24
netlify.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[build]
|
||||
command = "npm run build:prod"
|
||||
publish = "dist"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
|
||||
[build.environment]
|
||||
NODE_VERSION = "18"
|
||||
|
||||
[[headers]]
|
||||
for = "/*"
|
||||
[headers.values]
|
||||
X-Frame-Options = "SAMEORIGIN"
|
||||
X-Content-Type-Options = "nosniff"
|
||||
X-XSS-Protection = "1; mode=block"
|
||||
Referrer-Policy = "strict-origin-when-cross-origin"
|
||||
|
||||
[[headers]]
|
||||
for = "/assets/*"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
34
nginx.conf
Normal file
34
nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
2491
package-lock.json
generated
2491
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
|
|
@ -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
7
public/admin-icon.svg
Normal 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 |
|
|
@ -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 |
26
src/App.tsx
26
src/App.tsx
|
|
@ -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>
|
||||
|
|
|
|||
87
src/components/ErrorBoundary.tsx
Normal file
87
src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/components/ProtectedRoute.tsx
Normal file
17
src/components/ProtectedRoute.tsx
Normal 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}</>
|
||||
}
|
||||
92
src/components/__tests__/ErrorBoundary.test.tsx
Normal file
92
src/components/__tests__/ErrorBoundary.test.tsx
Normal 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
|
||||
})
|
||||
})
|
||||
36
src/components/__tests__/ProtectedRoute.test.tsx
Normal file
36
src/components/__tests__/ProtectedRoute.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
21
src/components/ui/badge-variants.ts
Normal file
21
src/components/ui/badge-variants.ts
Normal 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",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
31
src/components/ui/button-variants.ts
Normal file
31
src/components/ui/button-variants.ts
Normal 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",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
50
src/lib/__tests__/utils.test.ts
Normal file
50
src/lib/__tests__/utils.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
192
src/lib/error-tracker.ts
Normal 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
46
src/lib/sentry.ts
Normal 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 }
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
104
src/pages/login/__tests__/index.test.tsx
Normal file
104
src/pages/login/__tests__/index.test.tsx
Normal 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
127
src/pages/login/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
200
src/services/__tests__/auth.service.test.ts
Normal file
200
src/services/__tests__/auth.service.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
118
src/services/analytics.service.ts
Normal file
118
src/services/analytics.service.ts
Normal 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()
|
||||
88
src/services/announcement.service.ts
Normal file
88
src/services/announcement.service.ts
Normal 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()
|
||||
23
src/services/api/__tests__/client.test.ts
Normal file
23
src/services/api/__tests__/client.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
87
src/services/api/client.ts
Normal file
87
src/services/api/client.ts
Normal 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
|
||||
101
src/services/audit.service.ts
Normal file
101
src/services/audit.service.ts
Normal 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()
|
||||
99
src/services/auth.service.ts
Normal file
99
src/services/auth.service.ts
Normal 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()
|
||||
59
src/services/dashboard.service.ts
Normal file
59
src/services/dashboard.service.ts
Normal 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
23
src/services/index.ts
Normal 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'
|
||||
133
src/services/notification.service.ts
Normal file
133
src/services/notification.service.ts
Normal 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()
|
||||
116
src/services/security.service.ts
Normal file
116
src/services/security.service.ts
Normal 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()
|
||||
78
src/services/settings.service.ts
Normal file
78
src/services/settings.service.ts
Normal 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()
|
||||
99
src/services/system.service.ts
Normal file
99
src/services/system.service.ts
Normal 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()
|
||||
140
src/services/user.service.ts
Normal file
140
src/services/user.service.ts
Normal 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
62
src/test/setup.ts
Normal 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
41
src/test/test-utils.tsx
Normal 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
9
src/test/vitest.d.ts
vendored
Normal 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> {}
|
||||
}
|
||||
18
src/types/activity.types.ts
Normal file
18
src/types/activity.types.ts
Normal 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
|
||||
}
|
||||
46
src/types/analytics.types.ts
Normal file
46
src/types/analytics.types.ts
Normal 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
63
src/types/common.types.ts
Normal 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
15
src/types/error.types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
66
src/types/security.types.ts
Normal file
66
src/types/security.types.ts
Normal 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
|
||||
}
|
||||
42
vercel.json
Normal file
42
vercel.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"buildCommand": "npm run build:prod",
|
||||
"outputDirectory": "dist",
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "X-Frame-Options",
|
||||
"value": "SAMEORIGIN"
|
||||
},
|
||||
{
|
||||
"key": "X-Content-Type-Options",
|
||||
"value": "nosniff"
|
||||
},
|
||||
{
|
||||
"key": "X-XSS-Protection",
|
||||
"value": "1; mode=block"
|
||||
},
|
||||
{
|
||||
"key": "Referrer-Policy",
|
||||
"value": "strict-origin-when-cross-origin"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/assets/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "public, max-age=31536000, immutable"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
36
vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user