Merge branch 'main' of https://gitea.yaltopia.com/YaltopiaTech/Yaltopia-Ticket-Admin
This commit is contained in:
commit
2f8bd423ca
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 }}
|
||||||
52
.github/workflows/deploy.yml
vendored
Normal file
52
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
name: Build Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Production Artifacts
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test:run
|
||||||
|
|
||||||
|
- name: Build for production
|
||||||
|
run: npm run build:prod
|
||||||
|
env:
|
||||||
|
VITE_BACKEND_API_URL: ${{ secrets.VITE_BACKEND_API_URL_PROD }}
|
||||||
|
VITE_ENV: production
|
||||||
|
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||||
|
|
||||||
|
- name: Upload production artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: production-build
|
||||||
|
path: dist/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: docker build -t yaltopia-admin:${{ github.sha }} .
|
||||||
|
|
||||||
|
- name: Build success notification
|
||||||
|
if: success()
|
||||||
|
run: echo "Production build successful!"
|
||||||
|
|
||||||
|
- name: Build failure notification
|
||||||
|
if: failure()
|
||||||
|
run: echo "Production build failed!"
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -10,8 +10,15 @@ lerna-debug.log*
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
coverage
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.env.development
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.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;"]
|
||||||
278
README.md
278
README.md
|
|
@ -1,73 +1,233 @@
|
||||||
# React + TypeScript + Vite
|
# Yaltopia Ticket Admin
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
Admin dashboard for Yaltopia Ticket management system built with React, TypeScript, and Vite.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
> 📚 **For detailed documentation, see [dev-docs/](./dev-docs/README.md)**
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
## Features
|
||||||
- [@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
|
|
||||||
|
|
||||||
## 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
|
- Node.js 18+
|
||||||
export default defineConfig([
|
- npm or yarn
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
## Getting Started
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
### 1. Clone the repository
|
||||||
],
|
|
||||||
languageOptions: {
|
```bash
|
||||||
parserOptions: {
|
git clone <repository-url>
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
cd yaltopia-ticket-admin
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```bash
|
||||||
// eslint.config.js
|
npm install
|
||||||
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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 3. Environment Configuration
|
||||||
|
|
||||||
|
Copy the example environment file and configure it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and set your API URL:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_BACKEND_API_URL=http://localhost:3000/api/v1
|
||||||
|
VITE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run development server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at `http://localhost:5173`
|
||||||
|
|
||||||
|
## Building for Production
|
||||||
|
|
||||||
|
### 1. Configure production environment
|
||||||
|
|
||||||
|
Copy the production environment example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.production.example .env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env.production` with your production API URL:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_BACKEND_API_URL=https://api.yourdomain.com/api/v1
|
||||||
|
VITE_ENV=production
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build the application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
The production build will be in the `dist` directory.
|
||||||
|
|
||||||
|
### 3. Preview production build locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Docker Deployment (Recommended)
|
||||||
|
|
||||||
|
1. Build the Docker image:
|
||||||
|
```bash
|
||||||
|
docker build -t yaltopia-admin:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the container:
|
||||||
|
```bash
|
||||||
|
docker run -p 8080:80 yaltopia-admin:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Deploy to your cloud provider (AWS, GCP, Azure, DigitalOcean, etc.)
|
||||||
|
|
||||||
|
### Traditional VPS Deployment
|
||||||
|
|
||||||
|
1. Build the application: `npm run build:prod`
|
||||||
|
2. Copy the `dist` directory to your web server
|
||||||
|
3. Configure nginx or Apache to serve the static files
|
||||||
|
4. Set up redirects for SPA routing (see nginx.conf example)
|
||||||
|
|
||||||
|
### SPA Routing Configuration
|
||||||
|
|
||||||
|
For proper routing with nginx, use the included `nginx.conf` file or add this configuration:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The project includes a `Dockerfile` and `nginx.conf` for containerized deployment.
|
||||||
|
|
||||||
|
See [DEPLOYMENT.md](dev-docs/DEPLOYMENT.md) for detailed deployment instructions.
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Build and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t yaltopia-admin .
|
||||||
|
docker run -p 80:80 yaltopia-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Required | Default |
|
||||||
|
|----------|-------------|----------|---------|
|
||||||
|
| `VITE_BACKEND_API_URL` | Backend API base URL | Yes | `http://localhost:3000/api/v1` |
|
||||||
|
| `VITE_ENV` | Environment name | No | `development` |
|
||||||
|
| `VITE_ANALYTICS_ID` | Analytics tracking ID | No | - |
|
||||||
|
| `VITE_SENTRY_DSN` | Sentry error tracking DSN | No | - |
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `npm run dev` - Start development server
|
||||||
|
- `npm run build` - Build for production
|
||||||
|
- `npm run build:prod` - Build with production environment
|
||||||
|
- `npm run preview` - Preview production build
|
||||||
|
- `npm run lint` - Run ESLint
|
||||||
|
- `npm run lint:fix` - Fix ESLint errors
|
||||||
|
- `npm run type-check` - Run TypeScript type checking
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # App configuration (query client)
|
||||||
|
├── assets/ # Static assets
|
||||||
|
├── components/ # Reusable UI components
|
||||||
|
│ └── ui/ # Shadcn UI components
|
||||||
|
├── layouts/ # Layout components
|
||||||
|
├── lib/ # Utilities and API client
|
||||||
|
├── pages/ # Page components
|
||||||
|
│ ├── admin/ # Admin pages
|
||||||
|
│ ├── dashboard/ # Dashboard pages
|
||||||
|
│ └── ...
|
||||||
|
├── App.tsx # Main app component
|
||||||
|
├── main.tsx # App entry point
|
||||||
|
└── index.css # Global styles
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Current Implementation
|
||||||
|
|
||||||
|
- JWT tokens stored in localStorage
|
||||||
|
- Token automatically attached to API requests
|
||||||
|
- Automatic redirect to login on 401 errors
|
||||||
|
- Error handling for common HTTP status codes
|
||||||
|
|
||||||
|
### Production Recommendations
|
||||||
|
|
||||||
|
1. **Use httpOnly cookies** instead of localStorage for tokens
|
||||||
|
2. **Implement HTTPS** - Never deploy without SSL/TLS
|
||||||
|
3. **Add security headers** - CSP, HSTS, X-Frame-Options
|
||||||
|
4. **Enable CORS** properly on your backend
|
||||||
|
5. **Implement rate limiting** on authentication endpoints
|
||||||
|
6. **Add error boundary** for graceful error handling
|
||||||
|
7. **Set up monitoring** (Sentry, LogRocket, etc.)
|
||||||
|
8. **Regular security audits** - Run `npm audit` regularly
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
- Chrome (latest)
|
||||||
|
- Firefox (latest)
|
||||||
|
- Safari (latest)
|
||||||
|
- Edge (latest)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Create a feature branch
|
||||||
|
2. Make your changes
|
||||||
|
3. Run linting and type checking
|
||||||
|
4. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Proprietary - All rights reserved
|
||||||
|
|
||||||
|
|
|
||||||
983
dev-docs/API_GUIDE.md
Normal file
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
|
||||||
255
dev-docs/DEPLOYMENT.md
Normal file
255
dev-docs/DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
## Pre-Deployment Checklist
|
||||||
|
|
||||||
|
### 1. Code Quality
|
||||||
|
- [ ] All TypeScript errors resolved
|
||||||
|
- [ ] ESLint warnings addressed
|
||||||
|
- [ ] Build completes successfully
|
||||||
|
- [ ] No console errors in production build
|
||||||
|
|
||||||
|
### 2. Environment Configuration
|
||||||
|
- [ ] `.env.production` configured with production API URL
|
||||||
|
- [ ] All required environment variables set
|
||||||
|
- [ ] API endpoints tested and accessible
|
||||||
|
- [ ] CORS configured on backend for production domain
|
||||||
|
|
||||||
|
### 3. Security
|
||||||
|
- [ ] HTTPS enabled (SSL/TLS certificate)
|
||||||
|
- [ ] Security headers configured (CSP, HSTS, X-Frame-Options)
|
||||||
|
- [ ] Authentication tokens secured (consider httpOnly cookies)
|
||||||
|
- [ ] API keys and secrets not exposed in client code
|
||||||
|
- [ ] Rate limiting configured on backend
|
||||||
|
- [ ] Input validation on all forms
|
||||||
|
|
||||||
|
### 4. Performance
|
||||||
|
- [ ] Code splitting implemented (check vite.config.ts)
|
||||||
|
- [ ] Images optimized
|
||||||
|
- [ ] Lazy loading for routes (if needed)
|
||||||
|
- [ ] Bundle size analyzed and optimized
|
||||||
|
- [ ] CDN configured for static assets (optional)
|
||||||
|
|
||||||
|
### 5. Monitoring & Error Tracking
|
||||||
|
- [ ] Error boundary implemented ✓
|
||||||
|
- [ ] Error tracking service configured (Sentry, LogRocket, etc.)
|
||||||
|
- [ ] Analytics configured (Google Analytics, Plausible, etc.)
|
||||||
|
- [ ] Logging strategy defined
|
||||||
|
- [ ] Uptime monitoring configured
|
||||||
|
|
||||||
|
### 6. Testing
|
||||||
|
- [ ] Manual testing completed on staging
|
||||||
|
- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge)
|
||||||
|
- [ ] Mobile responsiveness verified
|
||||||
|
- [ ] Authentication flow tested
|
||||||
|
- [ ] API error handling tested
|
||||||
|
|
||||||
|
### 7. Documentation
|
||||||
|
- [ ] README.md updated ✓
|
||||||
|
- [ ] Environment variables documented ✓
|
||||||
|
- [ ] Deployment instructions clear ✓
|
||||||
|
- [ ] API documentation available
|
||||||
|
|
||||||
|
## Deployment Options
|
||||||
|
|
||||||
|
### Option 1: Docker + Cloud Provider (Recommended)
|
||||||
|
|
||||||
|
1. Build Docker image:
|
||||||
|
```bash
|
||||||
|
docker build -t yaltopia-admin:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Test locally:
|
||||||
|
```bash
|
||||||
|
docker run -p 8080:80 yaltopia-admin:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Push to container registry:
|
||||||
|
```bash
|
||||||
|
# For Docker Hub
|
||||||
|
docker tag yaltopia-admin:latest username/yaltopia-admin:latest
|
||||||
|
docker push username/yaltopia-admin:latest
|
||||||
|
|
||||||
|
# For AWS ECR
|
||||||
|
aws ecr get-login-password --region region | docker login --username AWS --password-stdin account-id.dkr.ecr.region.amazonaws.com
|
||||||
|
docker tag yaltopia-admin:latest account-id.dkr.ecr.region.amazonaws.com/yaltopia-admin:latest
|
||||||
|
docker push account-id.dkr.ecr.region.amazonaws.com/yaltopia-admin:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Deploy to cloud:
|
||||||
|
- AWS ECS/Fargate
|
||||||
|
- Google Cloud Run
|
||||||
|
- Azure Container Instances
|
||||||
|
- DigitalOcean App Platform
|
||||||
|
|
||||||
|
### Option 2: Traditional VPS (Ubuntu/Debian)
|
||||||
|
|
||||||
|
1. SSH into your server
|
||||||
|
|
||||||
|
2. Install Node.js and nginx:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Clone repository:
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd yaltopia-ticket-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Install dependencies and build:
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run build:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Configure nginx:
|
||||||
|
```bash
|
||||||
|
sudo cp nginx.conf /etc/nginx/sites-available/yaltopia-admin
|
||||||
|
sudo ln -s /etc/nginx/sites-available/yaltopia-admin /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Copy build files:
|
||||||
|
```bash
|
||||||
|
sudo cp -r dist/* /var/www/html/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post-Deployment
|
||||||
|
|
||||||
|
### 1. Verification
|
||||||
|
- [ ] Application loads correctly
|
||||||
|
- [ ] All routes work (test deep links)
|
||||||
|
- [ ] API calls successful
|
||||||
|
- [ ] Authentication works
|
||||||
|
- [ ] No console errors
|
||||||
|
- [ ] Performance acceptable (Lighthouse score)
|
||||||
|
|
||||||
|
### 2. Monitoring Setup
|
||||||
|
- [ ] Error tracking active
|
||||||
|
- [ ] Analytics tracking
|
||||||
|
- [ ] Uptime monitoring configured
|
||||||
|
- [ ] Alert notifications set up
|
||||||
|
|
||||||
|
### 3. Backup & Rollback Plan
|
||||||
|
- [ ] Previous version tagged in git
|
||||||
|
- [ ] Rollback procedure documented
|
||||||
|
- [ ] Database backup (if applicable)
|
||||||
|
|
||||||
|
## Continuous Deployment
|
||||||
|
|
||||||
|
### GitHub Actions (Automated)
|
||||||
|
|
||||||
|
The `.github/workflows/ci.yml` file is configured for CI, and `.github/workflows/deploy.yml` builds production artifacts.
|
||||||
|
|
||||||
|
For automated deployment, you can extend the workflow to:
|
||||||
|
|
||||||
|
1. **Push Docker image to registry:**
|
||||||
|
```yaml
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_REGISTRY }}/yaltopia-admin:latest
|
||||||
|
${{ secrets.DOCKER_REGISTRY }}/yaltopia-admin:${{ github.sha }}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Deploy to your server via SSH:**
|
||||||
|
```yaml
|
||||||
|
- name: Deploy to production server
|
||||||
|
uses: appleboy/ssh-action@v1.0.0
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /opt/yaltopia-admin
|
||||||
|
docker pull ${{ secrets.DOCKER_REGISTRY }}/yaltopia-admin:latest
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Build Fails
|
||||||
|
- Check Node.js version (18+)
|
||||||
|
- Clear node_modules and reinstall: `rm -rf node_modules package-lock.json && npm install`
|
||||||
|
- Check for TypeScript errors: `npm run type-check`
|
||||||
|
|
||||||
|
### Blank Page After Deploy
|
||||||
|
- Check browser console for errors
|
||||||
|
- Verify API URL is correct
|
||||||
|
- Check nginx/server configuration for SPA routing
|
||||||
|
- Verify all environment variables are set
|
||||||
|
|
||||||
|
### API Calls Failing
|
||||||
|
- Check CORS configuration on backend
|
||||||
|
- Verify API URL in environment variables
|
||||||
|
- Check network tab in browser DevTools
|
||||||
|
- Verify authentication token handling
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
- Analyze bundle size: `npm run build -- --mode production`
|
||||||
|
- Check for large dependencies
|
||||||
|
- Implement code splitting
|
||||||
|
- Enable compression (gzip/brotli)
|
||||||
|
- Use CDN for static assets
|
||||||
|
|
||||||
|
## Security Hardening
|
||||||
|
|
||||||
|
### 1. Content Security Policy (CSP)
|
||||||
|
|
||||||
|
Add to nginx.conf or hosting platform headers:
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.yourdomain.com;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Additional Security Headers
|
||||||
|
|
||||||
|
```
|
||||||
|
Strict-Transport-Security: max-age=31536000; includeSubDomains
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
X-Frame-Options: SAMEORIGIN
|
||||||
|
X-XSS-Protection: 1; mode=block
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
Permissions-Policy: geolocation=(), microphone=(), camera=()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Rate Limiting
|
||||||
|
|
||||||
|
Implement on backend and consider using Cloudflare or similar CDN with DDoS protection.
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Regular Tasks
|
||||||
|
- [ ] Update dependencies monthly: `npm update`
|
||||||
|
- [ ] Security audit: `npm audit`
|
||||||
|
- [ ] Review error logs weekly
|
||||||
|
- [ ] Monitor performance metrics
|
||||||
|
- [ ] Backup configuration and data
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
1. Test updates in development
|
||||||
|
2. Deploy to staging
|
||||||
|
3. Run full test suite
|
||||||
|
4. Deploy to production during low-traffic period
|
||||||
|
5. Monitor for issues
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
- Check logs in error tracking service
|
||||||
|
- Review browser console errors
|
||||||
|
- Check server logs
|
||||||
|
- Contact backend team for API issues
|
||||||
294
dev-docs/DEVELOPMENT.md
Normal file
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 (Docker, VPS)
|
||||||
|
- Environment configuration
|
||||||
|
- CI/CD setup
|
||||||
|
|
||||||
|
### [Security Guide](./SECURITY.md)
|
||||||
|
Security best practices:
|
||||||
|
- Authentication & authorization
|
||||||
|
- Data protection
|
||||||
|
- Security headers
|
||||||
|
- CORS configuration
|
||||||
|
- Input validation
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Configure
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your backend URL
|
||||||
|
|
||||||
|
# Develop
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Test
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 Key Concepts
|
||||||
|
|
||||||
|
### Service Layer
|
||||||
|
All API calls go through typed service classes:
|
||||||
|
```typescript
|
||||||
|
import { userService } from '@/services'
|
||||||
|
const users = await userService.getUsers()
|
||||||
|
```
|
||||||
|
|
||||||
|
### React Query
|
||||||
|
Data fetching with caching:
|
||||||
|
```typescript
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: () => userService.getUsers()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protected Routes
|
||||||
|
Authentication required for admin routes:
|
||||||
|
```typescript
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route path="/admin/*" element={<AdminLayout />} />
|
||||||
|
</Route>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🆘 Need Help?
|
||||||
|
|
||||||
|
1. **Making API calls?** → [API & Service Layer Guide](./API_GUIDE.md)
|
||||||
|
2. **General development?** → [Development Guide](./DEVELOPMENT.md)
|
||||||
|
3. **Writing tests?** → [Testing Guide](./TESTING_GUIDE.md)
|
||||||
|
4. **Deploying?** → [Deployment Guide](./DEPLOYMENT.md)
|
||||||
|
5. **Security questions?** → [Security Guide](./SECURITY.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2024
|
||||||
|
**Maintained By:** Development Team
|
||||||
339
dev-docs/SECURITY.md
Normal file
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 reactHooks from 'eslint-plugin-react-hooks'
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
||||||
|
|
||||||
export default defineConfig([
|
export default tseslint.config(
|
||||||
globalIgnores(['dist']),
|
{ ignores: ['dist'] },
|
||||||
{
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
reactHooks.configs.flat.recommended,
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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"
|
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<title>yaltopia-ticket-admin</title>
|
<title>Yaltopia Ticket Admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2477
package-lock.json
generated
2477
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",
|
"name": "yaltopia-ticket-admin",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"build:prod": "tsc -b && vite build --mode production",
|
||||||
"lint": "eslint .",
|
"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": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
|
@ -21,8 +28,9 @@
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
|
"@sentry/react": "^10.39.0",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|
@ -36,20 +44,27 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@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/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
"@vitest/ui": "^4.0.18",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"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 { Navigate, Route, Routes } from "react-router-dom"
|
||||||
import { AppShell } from "@/layouts/app-shell"
|
import { AppShell } from "@/layouts/app-shell"
|
||||||
|
import { ProtectedRoute } from "@/components/ProtectedRoute"
|
||||||
|
import LoginPage from "@/pages/login"
|
||||||
import DashboardPage from "@/pages/admin/dashboard"
|
import DashboardPage from "@/pages/admin/dashboard"
|
||||||
import UsersPage from "@/pages/admin/users"
|
import UsersPage from "@/pages/admin/users"
|
||||||
import UserDetailsPage from "@/pages/admin/users/[id]"
|
import UserDetailsPage from "@/pages/admin/users/[id]"
|
||||||
import UserActivityPage from "@/pages/admin/users/[id]/activity"
|
import UserActivityPage from "@/pages/admin/users/[id]/activity"
|
||||||
import LogsPage from "@/pages/admin/logs"
|
import ActivityLogPage from "@/pages/activity-log"
|
||||||
import ErrorLogsPage from "@/pages/admin/logs/errors"
|
|
||||||
import AccessLogsPage from "@/pages/admin/logs/access"
|
|
||||||
import LogDetailsPage from "@/pages/admin/logs/[id]"
|
|
||||||
import SettingsPage from "@/pages/admin/settings"
|
import SettingsPage from "@/pages/admin/settings"
|
||||||
import MaintenancePage from "@/pages/admin/maintenance"
|
import MaintenancePage from "@/pages/admin/maintenance"
|
||||||
import AnnouncementsPage from "@/pages/admin/announcements"
|
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 AnalyticsStoragePage from "@/pages/admin/analytics/storage"
|
||||||
import AnalyticsApiPage from "@/pages/admin/analytics/api"
|
import AnalyticsApiPage from "@/pages/admin/analytics/api"
|
||||||
import HealthPage from "@/pages/admin/health"
|
import HealthPage from "@/pages/admin/health"
|
||||||
|
import NotificationsPage from "@/pages/notifications"
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
<Route path="admin/dashboard" element={<DashboardPage />} />
|
<Route path="admin/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="admin/users" element={<UsersPage />} />
|
<Route path="admin/users" element={<UsersPage />} />
|
||||||
<Route path="admin/users/:id" element={<UserDetailsPage />} />
|
<Route path="admin/users/:id" element={<UserDetailsPage />} />
|
||||||
<Route path="admin/users/:id/activity" element={<UserActivityPage />} />
|
<Route path="admin/users/:id/activity" element={<UserActivityPage />} />
|
||||||
<Route path="admin/logs" element={<LogsPage />} />
|
<Route path="admin/logs" element={<ActivityLogPage />} />
|
||||||
<Route path="admin/logs/errors" element={<ErrorLogsPage />} />
|
<Route path="admin/logs/errors" element={<ActivityLogPage />} />
|
||||||
<Route path="admin/logs/access" element={<AccessLogsPage />} />
|
<Route path="admin/logs/access" element={<ActivityLogPage />} />
|
||||||
<Route path="admin/logs/:id" element={<LogDetailsPage />} />
|
<Route path="admin/logs/:id" element={<ActivityLogPage />} />
|
||||||
<Route path="admin/settings" element={<SettingsPage />} />
|
<Route path="admin/settings" element={<SettingsPage />} />
|
||||||
<Route path="admin/maintenance" element={<MaintenancePage />} />
|
<Route path="admin/maintenance" element={<MaintenancePage />} />
|
||||||
<Route path="admin/announcements" element={<AnnouncementsPage />} />
|
<Route path="admin/announcements" element={<AnnouncementsPage />} />
|
||||||
|
|
@ -56,6 +63,7 @@ function App() {
|
||||||
<Route path="admin/analytics/storage" element={<AnalyticsStoragePage />} />
|
<Route path="admin/analytics/storage" element={<AnalyticsStoragePage />} />
|
||||||
<Route path="admin/analytics/api" element={<AnalyticsApiPage />} />
|
<Route path="admin/analytics/api" element={<AnalyticsApiPage />} />
|
||||||
<Route path="admin/health" element={<HealthPage />} />
|
<Route path="admin/health" element={<HealthPage />} />
|
||||||
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
</Routes>
|
</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 * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { badgeVariants } from "./badge-variants"
|
||||||
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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
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 * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "./button-variants"
|
||||||
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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
|
@ -54,4 +25,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
)
|
)
|
||||||
Button.displayName = "Button"
|
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 {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Users,
|
Users,
|
||||||
|
|
@ -11,16 +12,30 @@ import {
|
||||||
Activity,
|
Activity,
|
||||||
Heart,
|
Heart,
|
||||||
Search,
|
Search,
|
||||||
Mail,
|
|
||||||
Bell,
|
Bell,
|
||||||
LogOut,
|
LogOut,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import {
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { cn } from "@/lib/utils"
|
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 = [
|
const adminNavigationItems = [
|
||||||
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
||||||
|
|
@ -37,6 +52,22 @@ const adminNavigationItems = [
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const location = useLocation()
|
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) => {
|
const isActive = (path: string) => {
|
||||||
return location.pathname.startsWith(path)
|
return location.pathname.startsWith(path)
|
||||||
|
|
@ -50,6 +81,49 @@ export function AppShell() {
|
||||||
return item?.label || "Admin Panel"
|
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 (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="flex h-screen bg-background">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
|
|
@ -88,21 +162,18 @@ export function AppShell() {
|
||||||
<div className="p-4 border-t">
|
<div className="p-4 border-t">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarFallback>AD</AvatarFallback>
|
<AvatarFallback>{getUserInitials()}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">Admin User</p>
|
<p className="text-sm font-medium truncate">{getUserDisplayName()}</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">admin@example.com</p>
|
<p className="text-xs text-muted-foreground truncate">{user?.email || 'admin@example.com'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => {
|
onClick={handleLogout}
|
||||||
localStorage.removeItem('access_token')
|
|
||||||
window.location.href = '/login'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
Logout
|
Logout
|
||||||
|
|
@ -116,24 +187,50 @@ export function AppShell() {
|
||||||
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
|
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
|
||||||
<h1 className="text-2xl font-bold">{getPageTitle()}</h1>
|
<h1 className="text-2xl font-bold">{getPageTitle()}</h1>
|
||||||
<div className="flex items-center gap-4">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Quick Search..."
|
placeholder="Quick Search..."
|
||||||
className="pl-10 w-64"
|
className="pl-10 w-64"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</form>
|
||||||
<Button variant="ghost" size="icon" className="relative">
|
<Button variant="ghost" size="icon" className="relative" onClick={handleNotificationClick}>
|
||||||
<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">
|
|
||||||
<Bell className="w-5 h-5" />
|
<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>
|
</Button>
|
||||||
<Avatar>
|
<DropdownMenu>
|
||||||
<AvatarFallback>AD</AvatarFallback>
|
<DropdownMenuTrigger asChild>
|
||||||
</Avatar>
|
<Button variant="ghost" size="icon" className="rounded-full">
|
||||||
|
<Avatar>
|
||||||
|
<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>
|
</div>
|
||||||
</header>
|
</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 }
|
||||||
20
src/main.tsx
20
src/main.tsx
|
|
@ -6,14 +6,22 @@ import { BrowserRouter } from "react-router-dom"
|
||||||
import { QueryClientProvider } from "@tanstack/react-query"
|
import { QueryClientProvider } from "@tanstack/react-query"
|
||||||
import { queryClient } from "@/app/query-client"
|
import { queryClient } from "@/app/query-client"
|
||||||
import { Toaster } from "@/components/ui/toast"
|
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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<ErrorBoundary>
|
||||||
<BrowserRouter>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
<Toaster />
|
<App />
|
||||||
</BrowserRouter>
|
<Toaster />
|
||||||
</QueryClientProvider>
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
@ -10,14 +12,60 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} 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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-3xl font-bold">Activity Log</h2>
|
<h2 className="text-3xl font-bold">Activity Log</h2>
|
||||||
<Button>
|
<Button onClick={handleExport}>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Export Log
|
Export Log
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -33,109 +81,113 @@ export default function ActivityLogPage() {
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search activity..."
|
placeholder="Search activity..."
|
||||||
className="pl-10 w-64"
|
className="pl-10 w-64"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select className="px-3 py-2 border rounded-md text-sm">
|
<select
|
||||||
<option>All Actions</option>
|
className="px-3 py-2 border rounded-md text-sm"
|
||||||
<option>Create</option>
|
value={actionFilter}
|
||||||
<option>Update</option>
|
onChange={(e) => setActionFilter(e.target.value)}
|
||||||
<option>Delete</option>
|
>
|
||||||
<option>Login</option>
|
<option value="">All Actions</option>
|
||||||
<option>Logout</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>
|
||||||
<select className="px-3 py-2 border rounded-md text-sm">
|
<select
|
||||||
<option>All Users</option>
|
className="px-3 py-2 border rounded-md text-sm"
|
||||||
<option>Admin</option>
|
value={resourceTypeFilter}
|
||||||
<option>Manager</option>
|
onChange={(e) => setResourceTypeFilter(e.target.value)}
|
||||||
<option>User</option>
|
>
|
||||||
|
<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>
|
</select>
|
||||||
<Button variant="outline">
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
{isLoading ? (
|
||||||
<TableHeader>
|
<div className="text-center py-8">Loading activity logs...</div>
|
||||||
<TableRow>
|
) : (
|
||||||
<TableHead>Log ID</TableHead>
|
<>
|
||||||
<TableHead>User</TableHead>
|
<Table>
|
||||||
<TableHead>Action</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Entity</TableHead>
|
<TableRow>
|
||||||
<TableHead>Description</TableHead>
|
<TableHead>Log ID</TableHead>
|
||||||
<TableHead>IP Address</TableHead>
|
<TableHead>User</TableHead>
|
||||||
<TableHead>Timestamp</TableHead>
|
<TableHead>Action</TableHead>
|
||||||
<TableHead>Action</TableHead>
|
<TableHead>Resource</TableHead>
|
||||||
</TableRow>
|
<TableHead>Resource ID</TableHead>
|
||||||
</TableHeader>
|
<TableHead>IP Address</TableHead>
|
||||||
<TableBody>
|
<TableHead>Timestamp</TableHead>
|
||||||
<TableRow>
|
<TableHead>Actions</TableHead>
|
||||||
<TableCell className="font-medium">LOG001</TableCell>
|
</TableRow>
|
||||||
<TableCell>john.smith@example.com</TableCell>
|
</TableHeader>
|
||||||
<TableCell>
|
<TableBody>
|
||||||
<Badge className="bg-blue-500">Create</Badge>
|
{auditData?.data?.map((log: AuditLog) => (
|
||||||
</TableCell>
|
<TableRow key={log.id}>
|
||||||
<TableCell>Client</TableCell>
|
<TableCell className="font-medium">{log.id}</TableCell>
|
||||||
<TableCell>Created new client record</TableCell>
|
<TableCell>{log.userId || 'N/A'}</TableCell>
|
||||||
<TableCell>192.168.1.1</TableCell>
|
<TableCell>
|
||||||
<TableCell>2024-01-15 10:30:45</TableCell>
|
<Badge className={getActionBadgeColor(log.action)}>
|
||||||
<TableCell>
|
{log.action}
|
||||||
<div className="flex items-center gap-2">
|
</Badge>
|
||||||
<Button variant="ghost" size="icon">
|
</TableCell>
|
||||||
<Eye className="w-4 h-4" />
|
<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>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
<Button variant="ghost" size="icon">
|
<Button
|
||||||
<MoreVertical className="w-4 h-4" />
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</div>
|
||||||
</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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,23 +8,18 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} 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() {
|
export default function AnalyticsApiPage() {
|
||||||
const { data: apiUsage, isLoading } = useQuery({
|
const { data: apiUsage, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'analytics', 'api-usage'],
|
queryKey: ['admin', 'analytics', 'api-usage'],
|
||||||
queryFn: async () => {
|
queryFn: () => analyticsService.getApiUsage(7),
|
||||||
const response = await adminApiHelpers.getApiUsage(7)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
|
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
|
||||||
queryKey: ['admin', 'analytics', 'error-rate'],
|
queryKey: ['admin', 'analytics', 'error-rate'],
|
||||||
queryFn: async () => {
|
queryFn: () => analyticsService.getErrorRate(7),
|
||||||
const response = await adminApiHelpers.getErrorRate(7)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -90,11 +85,11 @@ export default function AnalyticsApiPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{apiUsage?.map((endpoint: any, index: number) => (
|
{apiUsage?.map((endpoint: ApiUsageData, index: number) => (
|
||||||
<TableRow key={index}>
|
<TableRow key={index}>
|
||||||
<TableCell className="font-mono text-sm">{endpoint.endpoint}</TableCell>
|
<TableCell className="font-mono text-sm">{endpoint.date}</TableCell>
|
||||||
<TableCell>{endpoint.calls}</TableCell>
|
<TableCell>{endpoint.requests}</TableCell>
|
||||||
<TableCell>{endpoint.avgDuration?.toFixed(2) || 'N/A'}</TableCell>
|
<TableCell>{endpoint.avgResponseTime?.toFixed(2) || 'N/A'}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
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 { BarChart3, Users, DollarSign, HardDrive, Activity } from "lucide-react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
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() {
|
export default function AnalyticsRevenuePage() {
|
||||||
const { data: revenue, isLoading } = useQuery({
|
const { data: revenue, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'analytics', 'revenue'],
|
queryKey: ['admin', 'analytics', 'revenue'],
|
||||||
queryFn: async () => {
|
queryFn: () => analyticsService.getRevenue('90days'),
|
||||||
const response = await adminApiHelpers.getRevenue('90days')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"
|
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']
|
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8']
|
||||||
|
|
||||||
|
interface ChartDataItem {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnalyticsStoragePage() {
|
export default function AnalyticsStoragePage() {
|
||||||
const { data: storage, isLoading } = useQuery({
|
const { data: storage, isLoading } = useQuery<StorageAnalytics>({
|
||||||
queryKey: ['admin', 'analytics', 'storage'],
|
queryKey: ['admin', 'analytics', 'storage'],
|
||||||
queryFn: async () => {
|
queryFn: () => analyticsService.getStorageAnalytics(),
|
||||||
const response = await adminApiHelpers.getStorageAnalytics()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
|
|
@ -22,7 +25,7 @@ export default function AnalyticsStoragePage() {
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
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,
|
name: cat.category,
|
||||||
value: cat.size,
|
value: cat.size,
|
||||||
})) || []
|
})) || []
|
||||||
|
|
@ -71,12 +74,12 @@ export default function AnalyticsStoragePage() {
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
labelLine={false}
|
labelLine={false}
|
||||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
|
||||||
outerRadius={80}
|
outerRadius={80}
|
||||||
fill="#8884d8"
|
fill="#8884d8"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
>
|
>
|
||||||
{chartData.map((entry: any, index: number) => (
|
{chartData.map((_entry: ChartDataItem, index: number) => (
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
|
|
@ -100,13 +103,13 @@ export default function AnalyticsStoragePage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<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 key={index} className="flex items-center justify-between p-2 border rounded">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{user.user}</p>
|
<p className="font-medium">{user.userName || user.email}</p>
|
||||||
<p className="text-sm text-muted-foreground">{user.files} files</p>
|
<p className="text-sm text-muted-foreground">{user.documentCount} files</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium">{formatBytes(user.size)}</p>
|
<p className="font-medium">{formatBytes(user.storageUsed)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
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() {
|
export default function AnalyticsUsersPage() {
|
||||||
const { data: userGrowth, isLoading } = useQuery({
|
const { data: userGrowth, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'analytics', 'users', 'growth'],
|
queryKey: ['admin', 'analytics', 'users', 'growth'],
|
||||||
queryFn: async () => {
|
queryFn: () => analyticsService.getUserGrowth(90),
|
||||||
const response = await adminApiHelpers.getUserGrowth(90)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -19,41 +19,127 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Plus, Edit, Trash2 } from "lucide-react"
|
||||||
import { Label } from "@/components/ui/label"
|
import { announcementService, type Announcement, type CreateAnnouncementData } from "@/services"
|
||||||
import { Megaphone, Plus, Edit, Trash2 } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
|
import type { ApiError } from "@/types/error.types"
|
||||||
|
|
||||||
export default function AnnouncementsPage() {
|
export default function AnnouncementsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = 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({
|
const { data: announcements, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'announcements'],
|
queryKey: ['admin', 'announcements'],
|
||||||
queryFn: async () => {
|
queryFn: () => announcementService.getAnnouncements(false),
|
||||||
const response = await adminApiHelpers.getAnnouncements(false)
|
})
|
||||||
return response.data
|
|
||||||
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (id: string) => {
|
mutationFn: (id: string) => announcementService.deleteAnnouncement(id),
|
||||||
await adminApiHelpers.deleteAnnouncement(id)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
|
||||||
toast.success("Announcement deleted successfully")
|
toast.success("Announcement deleted successfully")
|
||||||
setDeleteDialogOpen(false)
|
setDeleteDialogOpen(false)
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error) => {
|
||||||
toast.error(error.response?.data?.message || "Failed to delete announcement")
|
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 = () => {
|
const handleDelete = () => {
|
||||||
if (selectedAnnouncement) {
|
if (selectedAnnouncement) {
|
||||||
deleteMutation.mutate(selectedAnnouncement.id)
|
deleteMutation.mutate(selectedAnnouncement.id)
|
||||||
|
|
@ -64,7 +150,7 @@ export default function AnnouncementsPage() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-3xl font-bold">Announcements</h2>
|
<h2 className="text-3xl font-bold">Announcements</h2>
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
<Button onClick={handleOpenCreateDialog}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Create Announcement
|
Create Announcement
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -92,7 +178,7 @@ export default function AnnouncementsPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{announcements?.map((announcement: any) => (
|
{announcements?.map((announcement: Announcement) => (
|
||||||
<TableRow key={announcement.id}>
|
<TableRow key={announcement.id}>
|
||||||
<TableCell className="font-medium">{announcement.title}</TableCell>
|
<TableCell className="font-medium">{announcement.title}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
@ -112,7 +198,11 @@ export default function AnnouncementsPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -141,6 +231,122 @@ export default function AnnouncementsPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Delete Dialog */}
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|
@ -163,4 +369,3 @@ export default function AnnouncementsPage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,21 +13,20 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { Search, Eye } from "lucide-react"
|
import { Search, Eye } from "lucide-react"
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
import { auditService, type AuditLog } from "@/services"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
|
|
||||||
export default function AuditPage() {
|
export default function AuditPage() {
|
||||||
const [page, setPage] = useState(1)
|
const [page] = useState(1)
|
||||||
const [limit] = useState(50)
|
const [limit] = useState(50)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const { data: auditData, isLoading } = useQuery({
|
const { data: auditData, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'audit', 'logs', page, limit, search],
|
queryKey: ['admin', 'audit', 'logs', page, limit, search],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params: any = { page, limit }
|
const params: Record<string, string | number> = { page, limit }
|
||||||
if (search) params.search = search
|
if (search) params.search = search
|
||||||
const response = await adminApiHelpers.getAuditLogs(params)
|
return await auditService.getAuditLogs(params)
|
||||||
return response.data
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -70,7 +69,7 @@ export default function AuditPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{auditData?.data?.map((log: any) => (
|
{auditData?.data?.map((log: AuditLog) => (
|
||||||
<TableRow key={log.id}>
|
<TableRow key={log.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge>{log.action}</Badge>
|
<Badge>{log.action}</Badge>
|
||||||
|
|
@ -80,7 +79,7 @@ export default function AuditPage() {
|
||||||
<TableCell>{log.userId || 'N/A'}</TableCell>
|
<TableCell>{log.userId || 'N/A'}</TableCell>
|
||||||
<TableCell>{log.ipAddress || 'N/A'}</TableCell>
|
<TableCell>{log.ipAddress || 'N/A'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{format(new Date(log.createdAt), 'MMM dd, yyyy HH:mm')}
|
{format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,72 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Download, Users, FileText, DollarSign, HardDrive, TrendingUp, AlertCircle } from "lucide-react"
|
import { Download, Users, FileText, DollarSign, HardDrive, AlertCircle } from "lucide-react"
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
import { analyticsService, systemService } from "@/services"
|
||||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { data: overview, isLoading: overviewLoading } = useQuery({
|
const { data: overview, isLoading: overviewLoading } = useQuery({
|
||||||
queryKey: ['admin', 'analytics', 'overview'],
|
queryKey: ['admin', 'analytics', 'overview'],
|
||||||
queryFn: async () => {
|
queryFn: () => analyticsService.getOverview(),
|
||||||
const response = await adminApiHelpers.getOverview()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: userGrowth, isLoading: growthLoading } = useQuery({
|
const { data: userGrowth, isLoading: growthLoading } = useQuery({
|
||||||
queryKey: ['admin', 'analytics', 'users', 'growth'],
|
queryKey: ['admin', 'analytics', 'users', 'growth'],
|
||||||
queryFn: async () => {
|
queryFn: () => analyticsService.getUserGrowth(30),
|
||||||
const response = await adminApiHelpers.getUserGrowth(30)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: revenue, isLoading: revenueLoading } = useQuery({
|
const { data: revenue, isLoading: revenueLoading } = useQuery({
|
||||||
queryKey: ['admin', 'analytics', 'revenue'],
|
queryKey: ['admin', 'analytics', 'revenue'],
|
||||||
queryFn: async () => {
|
queryFn: () => analyticsService.getRevenue('30days'),
|
||||||
const response = await adminApiHelpers.getRevenue('30days')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: health, isLoading: healthLoading } = useQuery({
|
const { data: health, isLoading: healthLoading } = useQuery({
|
||||||
queryKey: ['admin', 'system', 'health'],
|
queryKey: ['admin', 'system', 'health'],
|
||||||
queryFn: async () => {
|
queryFn: () => systemService.getHealth(),
|
||||||
const response = await adminApiHelpers.getHealth()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
|
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
|
||||||
queryKey: ['admin', 'analytics', 'error-rate'],
|
queryKey: ['admin', 'analytics', 'error-rate'],
|
||||||
queryFn: async () => {
|
queryFn: () => analyticsService.getErrorRate(7),
|
||||||
const response = await adminApiHelpers.getErrorRate(7)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleExport = () => {
|
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) => {
|
const formatCurrency = (amount: number) => {
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,22 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { AlertCircle, CheckCircle, XCircle, Database, Users, Activity } from "lucide-react"
|
import { AlertCircle, CheckCircle, XCircle, Users } from "lucide-react"
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
import { systemService } from "@/services"
|
||||||
|
|
||||||
export default function HealthPage() {
|
export default function HealthPage() {
|
||||||
const { data: health, isLoading: healthLoading } = useQuery({
|
const { data: health, isLoading: healthLoading } = useQuery({
|
||||||
queryKey: ['admin', 'system', 'health'],
|
queryKey: ['admin', 'system', 'health'],
|
||||||
queryFn: async () => {
|
queryFn: () => systemService.getHealth(),
|
||||||
const response = await adminApiHelpers.getHealth()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
refetchInterval: 30000, // Refetch every 30 seconds
|
refetchInterval: 30000, // Refetch every 30 seconds
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: systemInfo, isLoading: infoLoading } = useQuery({
|
const { data: systemInfo, isLoading: infoLoading } = useQuery({
|
||||||
queryKey: ['admin', 'system', 'info'],
|
queryKey: ['admin', 'system', 'info'],
|
||||||
queryFn: async () => {
|
queryFn: () => systemService.getSystemInfo(),
|
||||||
const response = await adminApiHelpers.getSystemInfo()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
const getStatusIcon = (status?: string) => {
|
||||||
switch (status?.toLowerCase()) {
|
switch (status?.toLowerCase()) {
|
||||||
case 'healthy':
|
case 'healthy':
|
||||||
case 'connected':
|
case 'connected':
|
||||||
|
|
@ -142,19 +136,19 @@ export default function HealthPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Platform</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Architecture</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Uptime</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Environment</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Memory Usage</p>
|
<p className="text-sm text-muted-foreground">Memory Usage</p>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
import { systemService } from "@/services"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import type { ApiError } from "@/types/error.types"
|
||||||
|
|
||||||
export default function MaintenancePage() {
|
export default function MaintenancePage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -15,36 +15,31 @@ export default function MaintenancePage() {
|
||||||
|
|
||||||
const { data: status, isLoading } = useQuery({
|
const { data: status, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'maintenance'],
|
queryKey: ['admin', 'maintenance'],
|
||||||
queryFn: async () => {
|
queryFn: () => systemService.getMaintenanceStatus(),
|
||||||
const response = await adminApiHelpers.getMaintenanceStatus()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const enableMutation = useMutation({
|
const enableMutation = useMutation({
|
||||||
mutationFn: async (msg?: string) => {
|
mutationFn: (msg?: string) => systemService.enableMaintenance(msg),
|
||||||
await adminApiHelpers.enableMaintenance(msg)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
|
||||||
toast.success("Maintenance mode enabled")
|
toast.success("Maintenance mode enabled")
|
||||||
setMessage("")
|
setMessage("")
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error) => {
|
||||||
toast.error(error.response?.data?.message || "Failed to enable maintenance mode")
|
const apiError = error as ApiError
|
||||||
|
toast.error(apiError.response?.data?.message || "Failed to enable maintenance mode")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const disableMutation = useMutation({
|
const disableMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: () => systemService.disableMaintenance(),
|
||||||
await adminApiHelpers.disableMaintenance()
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
|
||||||
toast.success("Maintenance mode disabled")
|
toast.success("Maintenance mode disabled")
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error) => {
|
||||||
toast.error(error.response?.data?.message || "Failed to disable maintenance mode")
|
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>
|
return <div className="text-center py-8">Loading maintenance status...</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isEnabled = status?.status === 'ACTIVE'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-3xl font-bold">Maintenance Mode</h2>
|
<h2 className="text-3xl font-bold">Maintenance Mode</h2>
|
||||||
|
|
@ -68,8 +65,8 @@ export default function MaintenancePage() {
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>Maintenance Status</CardTitle>
|
<CardTitle>Maintenance Status</CardTitle>
|
||||||
<Badge variant={status?.enabled ? 'destructive' : 'default'}>
|
<Badge variant={isEnabled ? 'destructive' : 'default'}>
|
||||||
{status?.enabled ? 'Enabled' : 'Disabled'}
|
{isEnabled ? 'Enabled' : 'Disabled'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -82,12 +79,12 @@ export default function MaintenancePage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={status?.enabled || false}
|
checked={isEnabled}
|
||||||
onCheckedChange={handleToggle}
|
onCheckedChange={handleToggle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!status?.enabled && (
|
{!isEnabled && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="message">Maintenance Message (Optional)</Label>
|
<Label htmlFor="message">Maintenance Message (Optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -102,7 +99,7 @@ export default function MaintenancePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status?.enabled && status?.message && (
|
{isEnabled && status?.message && (
|
||||||
<div>
|
<div>
|
||||||
<Label>Current Message</Label>
|
<Label>Current Message</Label>
|
||||||
<p className="text-sm mt-2">{status.message}</p>
|
<p className="text-sm mt-2">{status.message}</p>
|
||||||
|
|
@ -113,4 +110,3 @@ export default function MaintenancePage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,32 +10,29 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { Key, Ban } from "lucide-react"
|
import { Ban } from "lucide-react"
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
import { securityService, type ApiKey } from "@/services"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
|
import type { ApiError } from "@/types/error.types"
|
||||||
|
|
||||||
export default function ApiKeysPage() {
|
export default function ApiKeysPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { data: apiKeys, isLoading } = useQuery({
|
const { data: apiKeys, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'security', 'api-keys'],
|
queryKey: ['admin', 'security', 'api-keys'],
|
||||||
queryFn: async () => {
|
queryFn: () => securityService.getAllApiKeys(),
|
||||||
const response = await adminApiHelpers.getAllApiKeys()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const revokeMutation = useMutation({
|
const revokeMutation = useMutation({
|
||||||
mutationFn: async (id: string) => {
|
mutationFn: (id: string) => securityService.revokeApiKey(id),
|
||||||
await adminApiHelpers.revokeApiKey(id)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'api-keys'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'api-keys'] })
|
||||||
toast.success("API key revoked successfully")
|
toast.success("API key revoked successfully")
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error) => {
|
||||||
toast.error(error.response?.data?.message || "Failed to revoke API key")
|
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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{apiKeys?.map((key: any) => (
|
{apiKeys?.map((key: ApiKey) => (
|
||||||
<TableRow key={key.id}>
|
<TableRow key={key.id}>
|
||||||
<TableCell className="font-medium">{key.name}</TableCell>
|
<TableCell className="font-medium">{key.name}</TableCell>
|
||||||
<TableCell>{key.userId || 'N/A'}</TableCell>
|
<TableCell>{key.userId || 'N/A'}</TableCell>
|
||||||
<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>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={key.revoked ? 'destructive' : 'default'}>
|
<Badge variant={key.isActive ? 'default' : 'destructive'}>
|
||||||
{key.revoked ? 'Revoked' : 'Active'}
|
{key.isActive ? 'Active' : 'Revoked'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{!key.revoked && (
|
{key.isActive && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -12,21 +13,20 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { Search, Ban } from "lucide-react"
|
import { Search, Ban } from "lucide-react"
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
import { securityService, type FailedLogin } from "@/services"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
|
|
||||||
export default function FailedLoginsPage() {
|
export default function FailedLoginsPage() {
|
||||||
const [page, setPage] = useState(1)
|
const [page] = useState(1)
|
||||||
const [limit] = useState(50)
|
const [limit] = useState(50)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const { data: failedLogins, isLoading } = useQuery({
|
const { data: failedLogins, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'security', 'failed-logins', page, limit, search],
|
queryKey: ['admin', 'security', 'failed-logins', page, limit, search],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params: any = { page, limit }
|
const params: Record<string, string | number> = { page, limit }
|
||||||
if (search) params.email = search
|
if (search) params.email = search
|
||||||
const response = await adminApiHelpers.getFailedLogins(params)
|
return await securityService.getFailedLogins(params)
|
||||||
return response.data
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -67,18 +67,18 @@ export default function FailedLoginsPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{failedLogins?.data?.map((login: any) => (
|
{failedLogins?.data?.map((login: FailedLogin) => (
|
||||||
<TableRow key={login.id}>
|
<TableRow key={login.id}>
|
||||||
<TableCell className="font-medium">{login.email}</TableCell>
|
<TableCell className="font-medium">{login.email}</TableCell>
|
||||||
<TableCell className="font-mono text-sm">{login.ipAddress}</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>{login.reason || 'N/A'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{format(new Date(login.attemptedAt), 'MMM dd, yyyy HH:mm')}
|
{format(new Date(login.timestamp), 'MMM dd, yyyy HH:mm')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={login.blocked ? 'destructive' : 'secondary'}>
|
<Badge variant="secondary">
|
||||||
{login.blocked ? 'Yes' : 'No'}
|
N/A
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
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 { Shield, AlertTriangle, Key, Gauge, Users } from "lucide-react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,13 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} 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() {
|
export default function RateLimitsPage() {
|
||||||
const { data: violations, isLoading } = useQuery({
|
const { data: violations, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'security', 'rate-limits'],
|
queryKey: ['admin', 'security', 'rate-limits'],
|
||||||
queryFn: async () => {
|
queryFn: () => securityService.getRateLimitViolations(7),
|
||||||
const response = await adminApiHelpers.getRateLimitViolations(7)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -42,7 +40,7 @@ export default function RateLimitsPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{violations?.map((violation: any) => (
|
{violations?.map((violation: RateLimitViolation) => (
|
||||||
<TableRow key={violation.id}>
|
<TableRow key={violation.id}>
|
||||||
<TableCell>{violation.userId || 'N/A'}</TableCell>
|
<TableCell>{violation.userId || 'N/A'}</TableCell>
|
||||||
<TableCell className="font-mono text-sm">{violation.ipAddress}</TableCell>
|
<TableCell className="font-mono text-sm">{violation.ipAddress}</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,13 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { LogOut } from "lucide-react"
|
import { LogOut } from "lucide-react"
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
import { securityService, type ActiveSession } from "@/services"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
|
|
||||||
export default function SessionsPage() {
|
export default function SessionsPage() {
|
||||||
const { data: sessions, isLoading } = useQuery({
|
const { data: sessions, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'security', 'sessions'],
|
queryKey: ['admin', 'security', 'sessions'],
|
||||||
queryFn: async () => {
|
queryFn: () => securityService.getActiveSessions(),
|
||||||
const response = await adminApiHelpers.getActiveSessions()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -46,7 +43,7 @@ export default function SessionsPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{sessions?.map((session: any) => (
|
{sessions?.map((session: ActiveSession) => (
|
||||||
<TableRow key={session.id}>
|
<TableRow key={session.id}>
|
||||||
<TableCell className="font-medium">{session.userId || 'N/A'}</TableCell>
|
<TableCell className="font-medium">{session.userId || 'N/A'}</TableCell>
|
||||||
<TableCell className="font-mono text-sm">{session.ipAddress}</TableCell>
|
<TableCell className="font-mono text-sm">{session.ipAddress}</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Shield, Ban } from "lucide-react"
|
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() {
|
export default function SuspiciousActivityPage() {
|
||||||
const { data: suspicious, isLoading } = useQuery({
|
const { data: suspicious, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'security', 'suspicious'],
|
queryKey: ['admin', 'security', 'suspicious'],
|
||||||
queryFn: async () => {
|
queryFn: () => securityService.getSuspiciousActivity(),
|
||||||
const response = await adminApiHelpers.getSuspiciousActivity()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -29,9 +26,9 @@ export default function SuspiciousActivityPage() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8">Loading...</div>
|
<div className="text-center py-8">Loading...</div>
|
||||||
) : suspicious?.suspiciousIPs?.length > 0 ? (
|
) : (suspicious?.suspiciousIPs?.length ?? 0) > 0 ? (
|
||||||
<div className="space-y-2">
|
<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 key={index} className="flex items-center justify-between p-2 border rounded">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-mono font-medium">{ip.ipAddress}</p>
|
<p className="font-mono font-medium">{ip.ipAddress}</p>
|
||||||
|
|
@ -62,9 +59,9 @@ export default function SuspiciousActivityPage() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8">Loading...</div>
|
<div className="text-center py-8">Loading...</div>
|
||||||
) : suspicious?.suspiciousEmails?.length > 0 ? (
|
) : (suspicious?.suspiciousEmails?.length ?? 0) > 0 ? (
|
||||||
<div className="space-y-2">
|
<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 key={index} className="flex items-center justify-between p-2 border rounded">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{email.email}</p>
|
<p className="font-medium">{email.email}</p>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,9 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Plus } from "lucide-react"
|
import { Plus } from "lucide-react"
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
import { settingsService, type Setting } from "@/services"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import type { ApiError } from "@/types/error.types"
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -31,43 +32,39 @@ export default function SettingsPage() {
|
||||||
|
|
||||||
const { data: settings, isLoading } = useQuery({
|
const { data: settings, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'settings', selectedCategory],
|
queryKey: ['admin', 'settings', selectedCategory],
|
||||||
queryFn: async () => {
|
queryFn: () => settingsService.getSettings(selectedCategory),
|
||||||
const response = await adminApiHelpers.getSettings(selectedCategory)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateSettingMutation = useMutation({
|
const updateSettingMutation = useMutation({
|
||||||
mutationFn: async ({ key, value }: { key: string; value: string }) => {
|
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||||
await adminApiHelpers.updateSetting(key, { value })
|
settingsService.updateSetting(key, { value }),
|
||||||
},
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
||||||
toast.success("Setting updated successfully")
|
toast.success("Setting updated successfully")
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error) => {
|
||||||
toast.error(error.response?.data?.message || "Failed to update setting")
|
const apiError = error as ApiError
|
||||||
|
toast.error(apiError.response?.data?.message || "Failed to update setting")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const createSettingMutation = useMutation({
|
const createSettingMutation = useMutation({
|
||||||
mutationFn: async (data: {
|
mutationFn: (data: {
|
||||||
key: string
|
key: string
|
||||||
value: string
|
value: string
|
||||||
category: string
|
category: string
|
||||||
description?: string
|
description?: string
|
||||||
isPublic?: boolean
|
isPublic?: boolean
|
||||||
}) => {
|
}) => settingsService.createSetting(data),
|
||||||
await adminApiHelpers.createSetting(data)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
||||||
toast.success("Setting created successfully")
|
toast.success("Setting created successfully")
|
||||||
setCreateDialogOpen(false)
|
setCreateDialogOpen(false)
|
||||||
setNewSetting({ key: "", value: "", description: "", isPublic: false })
|
setNewSetting({ key: "", value: "", description: "", isPublic: false })
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error) => {
|
||||||
toast.error(error.response?.data?.message || "Failed to create setting")
|
const apiError = error as ApiError
|
||||||
|
toast.error(apiError.response?.data?.message || "Failed to create setting")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -118,8 +115,8 @@ export default function SettingsPage() {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8">Loading settings...</div>
|
<div className="text-center py-8">Loading settings...</div>
|
||||||
) : settings && settings.length > 0 ? (
|
) : settings && settings.length > 0 ? (
|
||||||
settings.map((setting: any) => (
|
settings.map((setting: Setting) => (
|
||||||
<div key={setting.id} className="space-y-2">
|
<div key={setting.key} className="space-y-2">
|
||||||
<Label htmlFor={setting.key}>{setting.key}</Label>
|
<Label htmlFor={setting.key}>{setting.key}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,24 @@ import { useQuery } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft } from "lucide-react"
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
import { userService } from "@/services"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
action?: string
|
||||||
|
description?: string
|
||||||
|
message?: string
|
||||||
|
createdAt?: string
|
||||||
|
timestamp?: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserActivityPage() {
|
export default function UserActivityPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { data: activity, isLoading } = useQuery({
|
const { data: activity, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'users', id, 'activity'],
|
queryKey: ['admin', 'users', id, 'activity'],
|
||||||
queryFn: async () => {
|
queryFn: () => userService.getUserActivity(id!, 30),
|
||||||
const response = await adminApiHelpers.getUserActivity(id!, 30)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -39,7 +44,7 @@ export default function UserActivityPage() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activity && activity.length > 0 ? (
|
{activity && activity.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<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 key={index} className="border-l-2 pl-4 pb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -47,7 +52,7 @@ export default function UserActivityPage() {
|
||||||
<p className="text-sm text-muted-foreground">{item.description || item.message}</p>
|
<p className="text-sm text-muted-foreground">{item.description || item.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,78 @@
|
||||||
import { useParams, useNavigate } from "react-router-dom"
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { ArrowLeft, Edit, Key, Trash2 } from "lucide-react"
|
import {
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
Dialog,
|
||||||
import { toast } from "sonner"
|
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 { format } from "date-fns"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function UserDetailsPage() {
|
export default function UserDetailsPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
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],
|
queryKey: ['admin', 'users', id],
|
||||||
queryFn: async () => {
|
queryFn: () => userService.getUser(id!),
|
||||||
const response = await adminApiHelpers.getUser(id!)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateUserMutation = useMutation({
|
const handleEditClick = () => {
|
||||||
mutationFn: async (data: any) => {
|
if (user) {
|
||||||
await adminApiHelpers.updateUser(id!, data)
|
setEditForm({
|
||||||
},
|
firstName: user.firstName || '',
|
||||||
onSuccess: () => {
|
lastName: user.lastName || '',
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users', id] })
|
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")
|
toast.success("User updated successfully")
|
||||||
},
|
setIsEditDialogOpen(false)
|
||||||
onError: (error: any) => {
|
refetch()
|
||||||
toast.error(error.response?.data?.message || "Failed to update user")
|
} catch (error) {
|
||||||
},
|
toast.error("Failed to update user")
|
||||||
})
|
console.error('Update error:', error)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-center py-8">Loading user details...</div>
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>User Information</CardTitle>
|
<CardTitle>User Information</CardTitle>
|
||||||
<div className="flex gap-2">
|
<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 className="w-4 h-4 mr-2" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -105,7 +143,9 @@ export default function UserDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Updated At</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -149,6 +189,82 @@ export default function UserDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,21 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Search, Download, Eye, MoreVertical, UserPlus, Edit, Trash2, Key, Upload } from "lucide-react"
|
import { Search, Download, Eye, UserPlus, Trash2, Key, Upload } from "lucide-react"
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
import { userService } from "@/services"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { format } from "date-fns"
|
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() {
|
export default function UsersPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -42,7 +53,7 @@ export default function UsersPage() {
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [roleFilter, setRoleFilter] = useState<string>("all")
|
const [roleFilter, setRoleFilter] = useState<string>("all")
|
||||||
const [statusFilter, setStatusFilter] = 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 [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
|
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||||
|
|
@ -51,74 +62,66 @@ export default function UsersPage() {
|
||||||
const { data: usersData, isLoading } = useQuery({
|
const { data: usersData, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter],
|
queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params: any = { page, limit }
|
const params: Record<string, string | number | boolean> = { page, limit }
|
||||||
if (search) params.search = search
|
if (search) params.search = search
|
||||||
if (roleFilter !== 'all') params.role = roleFilter
|
if (roleFilter !== 'all') params.role = roleFilter
|
||||||
if (statusFilter !== 'all') params.isActive = statusFilter === 'active'
|
if (statusFilter !== 'all') params.isActive = statusFilter === 'active'
|
||||||
const response = await adminApiHelpers.getUsers(params)
|
return await userService.getUsers(params)
|
||||||
return response.data
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteUserMutation = useMutation({
|
const deleteUserMutation = useMutation({
|
||||||
mutationFn: async ({ id, hard }: { id: string; hard: boolean }) => {
|
mutationFn: ({ id, hard }: { id: string; hard: boolean }) =>
|
||||||
await adminApiHelpers.deleteUser(id, hard)
|
userService.deleteUser(id, hard),
|
||||||
},
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||||
toast.success("User deleted successfully")
|
toast.success("User deleted successfully")
|
||||||
setDeleteDialogOpen(false)
|
setDeleteDialogOpen(false)
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error) => {
|
||||||
toast.error(error.response?.data?.message || "Failed to delete user")
|
const apiError = error as ApiError
|
||||||
|
toast.error(apiError.response?.data?.message || "Failed to delete user")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const resetPasswordMutation = useMutation({
|
const resetPasswordMutation = useMutation({
|
||||||
mutationFn: async (id: string) => {
|
mutationFn: (id: string) => userService.resetPassword(id),
|
||||||
const response = await adminApiHelpers.resetPassword(id)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success(`Password reset. Temporary password: ${data.temporaryPassword}`)
|
toast.success(`Password reset. Temporary password: ${data.temporaryPassword}`)
|
||||||
setResetPasswordDialogOpen(false)
|
setResetPasswordDialogOpen(false)
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error) => {
|
||||||
toast.error(error.response?.data?.message || "Failed to reset password")
|
const apiError = error as ApiError
|
||||||
|
toast.error(apiError.response?.data?.message || "Failed to reset password")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const importUsersMutation = useMutation({
|
const importUsersMutation = useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: (file: File) => userService.importUsers(file),
|
||||||
const response = await adminApiHelpers.importUsers(file)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
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)
|
setImportDialogOpen(false)
|
||||||
setImportFile(null)
|
setImportFile(null)
|
||||||
if (data.errors && data.errors.length > 0) {
|
|
||||||
console.error('Import errors:', data.errors)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error) => {
|
||||||
toast.error(error.response?.data?.message || "Failed to import users")
|
const apiError = error as ApiError
|
||||||
|
toast.error(apiError.response?.data?.message || "Failed to import users")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await adminApiHelpers.exportUsers('csv')
|
const blob = await userService.exportUsers('csv')
|
||||||
const blob = new Blob([response.data], { type: 'text/csv' })
|
|
||||||
const url = window.URL.createObjectURL(blob)
|
const url = window.URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `users-${new Date().toISOString()}.csv`
|
a.download = `users-${new Date().toISOString()}.csv`
|
||||||
a.click()
|
a.click()
|
||||||
toast.success("Users exported successfully")
|
toast.success("Users exported successfully")
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
toast.error(error.response?.data?.message || "Failed to export users")
|
const apiError = error as ApiError
|
||||||
|
toast.error(apiError.response?.data?.message || "Failed to export users")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,7 +238,7 @@ export default function UsersPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{usersData?.data?.map((user: any) => (
|
{usersData?.data?.map((user: User) => (
|
||||||
<TableRow key={user.id}>
|
<TableRow key={user.id}>
|
||||||
<TableCell className="font-medium">{user.email}</TableCell>
|
<TableCell className="font-medium">{user.email}</TableCell>
|
||||||
<TableCell>{user.firstName} {user.lastName}</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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<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="flex items-center gap-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
01 Sep - 15 Sep 2024
|
{new Date().toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline">
|
<Button variant="outline" onClick={handleExport}>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Export Data
|
Export Data
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle>Welcome to Dashboard</CardTitle>
|
<CardTitle className="text-sm font-medium">Total Invoices</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-muted-foreground">
|
{isLoading ? (
|
||||||
This is your main dashboard page.
|
<div className="text-2xl font-bold">...</div>
|
||||||
</p>
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
</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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
@ -10,17 +12,161 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} 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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-3xl font-bold">Notifications</h2>
|
<div>
|
||||||
<Button>
|
<h2 className="text-3xl font-bold">Notifications</h2>
|
||||||
<Bell className="w-4 h-4 mr-2" />
|
{unreadCount !== undefined && unreadCount > 0 && (
|
||||||
Create Notification
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
</Button>
|
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" />
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -33,20 +179,32 @@ export default function NotificationsPage() {
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search notification..."
|
placeholder="Search notification..."
|
||||||
className="pl-10 w-64"
|
className="pl-10 w-64"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select className="px-3 py-2 border rounded-md text-sm">
|
<select
|
||||||
<option>All Types</option>
|
className="px-3 py-2 border rounded-md text-sm"
|
||||||
<option>System</option>
|
value={typeFilter}
|
||||||
<option>User</option>
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
<option>Alert</option>
|
>
|
||||||
|
<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>
|
||||||
<select className="px-3 py-2 border rounded-md text-sm">
|
<select
|
||||||
<option>All Status</option>
|
className="px-3 py-2 border rounded-md text-sm"
|
||||||
<option>Read</option>
|
value={statusFilter}
|
||||||
<option>Unread</option>
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="read">Read</option>
|
||||||
|
<option value="unread">Unread</option>
|
||||||
</select>
|
</select>
|
||||||
<Button variant="outline">
|
<Button variant="outline" onClick={handleExport}>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Export
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -54,68 +212,64 @@ export default function NotificationsPage() {
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
{isLoading ? (
|
||||||
<TableHeader>
|
<div className="flex items-center justify-center py-8">
|
||||||
<TableRow>
|
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||||
<TableHead>Notification ID</TableHead>
|
</div>
|
||||||
<TableHead>Title</TableHead>
|
) : filteredNotifications && filteredNotifications.length > 0 ? (
|
||||||
<TableHead>Type</TableHead>
|
<Table>
|
||||||
<TableHead>Recipient</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Status</TableHead>
|
<TableRow>
|
||||||
<TableHead>Created Date</TableHead>
|
<TableHead>Notification ID</TableHead>
|
||||||
<TableHead>Sent Date</TableHead>
|
<TableHead>Title</TableHead>
|
||||||
<TableHead>Action</TableHead>
|
<TableHead>Message</TableHead>
|
||||||
</TableRow>
|
<TableHead>Type</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Status</TableHead>
|
||||||
<TableBody>
|
<TableHead>Created Date</TableHead>
|
||||||
<TableRow>
|
<TableHead>Read Date</TableHead>
|
||||||
<TableCell className="font-medium">NOT001</TableCell>
|
<TableHead>Action</TableHead>
|
||||||
<TableCell>System Update Available</TableCell>
|
</TableRow>
|
||||||
<TableCell>
|
</TableHeader>
|
||||||
<Badge variant="outline">System</Badge>
|
<TableBody>
|
||||||
</TableCell>
|
{filteredNotifications.map((notification) => (
|
||||||
<TableCell>All Users</TableCell>
|
<TableRow key={notification.id} className={!notification.isRead ? 'bg-blue-50' : ''}>
|
||||||
<TableCell>
|
<TableCell className="font-medium">{notification.id}</TableCell>
|
||||||
<Badge className="bg-blue-500">Sent</Badge>
|
<TableCell className="font-medium">{notification.title}</TableCell>
|
||||||
</TableCell>
|
<TableCell className="max-w-xs truncate">{notification.message}</TableCell>
|
||||||
<TableCell>2024-01-15</TableCell>
|
<TableCell>
|
||||||
<TableCell>2024-01-15 10:00</TableCell>
|
<Badge variant="outline" className="capitalize">{notification.type}</Badge>
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<TableCell>
|
||||||
<Button variant="ghost" size="icon">
|
<Badge className={getStatusBadge(notification.isRead)}>
|
||||||
<Eye className="w-4 h-4" />
|
{notification.isRead ? 'Read' : 'Unread'}
|
||||||
</Button>
|
</Badge>
|
||||||
<Button variant="ghost" size="icon">
|
</TableCell>
|
||||||
<MoreVertical className="w-4 h-4" />
|
<TableCell>{formatDate(notification.createdAt)}</TableCell>
|
||||||
</Button>
|
<TableCell>{formatDateTime(notification.readAt)}</TableCell>
|
||||||
</div>
|
<TableCell>
|
||||||
</TableCell>
|
{!notification.isRead && (
|
||||||
</TableRow>
|
<Button
|
||||||
<TableRow>
|
variant="ghost"
|
||||||
<TableCell className="font-medium">NOT002</TableCell>
|
size="icon"
|
||||||
<TableCell>Payment Received</TableCell>
|
onClick={() => handleMarkAsRead(notification.id)}
|
||||||
<TableCell>
|
title="Mark as read"
|
||||||
<Badge variant="outline">User</Badge>
|
>
|
||||||
</TableCell>
|
<Eye className="w-4 h-4" />
|
||||||
<TableCell>john@example.com</TableCell>
|
</Button>
|
||||||
<TableCell>
|
)}
|
||||||
<Badge className="bg-green-500">Delivered</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
</TableRow>
|
||||||
<TableCell>2024-01-14</TableCell>
|
))}
|
||||||
<TableCell>2024-01-14 14:30</TableCell>
|
</TableBody>
|
||||||
<TableCell>
|
</Table>
|
||||||
<div className="flex items-center gap-2">
|
) : (
|
||||||
<Button variant="ghost" size="icon">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<Eye className="w-4 h-4" />
|
{searchQuery || typeFilter || statusFilter
|
||||||
</Button>
|
? 'No notifications match your filters'
|
||||||
<Button variant="ghost" size="icon">
|
: 'No notifications found'
|
||||||
<MoreVertical className="w-4 h-4" />
|
}
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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
|
||||||
|
}
|
||||||
|
|
@ -10,4 +10,28 @@ export default defineConfig({
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": 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