commit f09748f4a107707c10c95e85d8afd016b4807126 Author: Matthias Hochmeister Date: Mon Feb 23 17:08:58 2026 +0100 inital diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1fe5dde --- /dev/null +++ b/.env.example @@ -0,0 +1,279 @@ +# ============================================================================ +# FEUERWEHR DASHBOARD - ENVIRONMENT CONFIGURATION +# ============================================================================ +# This file contains all environment variables needed for the application. +# Copy this file to .env and fill in your actual values. +# +# IMPORTANT SECURITY NOTES: +# - Never commit .env file to version control +# - Use strong, randomly generated passwords for production +# - Rotate secrets regularly +# - Keep this file secure with restricted permissions (chmod 600 .env) +# ============================================================================ + +# ============================================================================ +# DATABASE CONFIGURATION +# ============================================================================ + +# Database name +# Default: feuerwehr_prod +# Development: feuerwehr_dev +POSTGRES_DB=feuerwehr_prod + +# Database user +# Default: prod_user +# Development: dev_user +POSTGRES_USER=prod_user + +# Database password +# REQUIRED in production! +# Generate with: openssl rand -base64 24 +# WARNING: Never use simple passwords in production! +POSTGRES_PASSWORD=your_secure_password_here + +# Database port +# Default: 5432 (PostgreSQL default) +# Change if port 5432 is already in use +POSTGRES_PORT=5432 + +# ============================================================================ +# BACKEND CONFIGURATION +# ============================================================================ + +# Backend API port +# Default: 3000 +# The port where the Node.js backend API will listen +BACKEND_PORT=3000 + +# Node environment +# Options: development | production | test +# Production: Enables optimizations and security features +# Development: Enables debug logging and hot reload +NODE_ENV=production + +# Database connection URL +# Auto-constructed in docker-compose.yml, but can be overridden +# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE +# For Docker: Use service name (postgres) as host +# For local dev: Use localhost +# DATABASE_URL=postgresql://prod_user:your_secure_password_here@postgres:5432/feuerwehr_prod + +# ============================================================================ +# JWT CONFIGURATION +# ============================================================================ + +# JWT Secret Key +# REQUIRED in production! +# Used to sign and verify JWT tokens +# Generate with: openssl rand -base64 32 +# WARNING: Keep this secret! Never share or commit this value! +# SECURITY: Change this value if it's ever compromised +JWT_SECRET=your_jwt_secret_here + +# JWT Token Expiration (optional) +# Access token expiration in seconds +# Default: 3600 (1 hour) +# JWT_ACCESS_EXPIRATION=3600 + +# Refresh token expiration in seconds +# Default: 86400 (24 hours) +# JWT_REFRESH_EXPIRATION=86400 + +# ============================================================================ +# CORS CONFIGURATION +# ============================================================================ + +# CORS Allowed Origin +# The frontend URL that is allowed to make requests to the backend +# IMPORTANT: Must match your frontend URL exactly! +# Development: http://localhost:5173 (Vite dev server) +# Production: https://dashboard.yourdomain.com +# Multiple origins: Use comma-separated values (if supported by your setup) +CORS_ORIGIN=http://localhost:80 + +# ============================================================================ +# FRONTEND CONFIGURATION +# ============================================================================ + +# Frontend port +# Default: 80 (HTTP) +# Use 443 for HTTPS (requires SSL certificate) +FRONTEND_PORT=80 + +# API URL for frontend +# The URL where the frontend will send API requests +# Development: http://localhost:3000 +# Production: https://api.yourdomain.com +# IMPORTANT: Must be accessible from the user's browser! +VITE_API_URL=http://localhost:3000 + +# ============================================================================ +# AUTHENTIK OAUTH CONFIGURATION +# ============================================================================ +# Get these values from your Authentik instance +# See AUTHENTIK_SETUP.md for detailed configuration guide + +# OAuth Client ID +# From Authentik: Applications → Providers → Your Provider +# REQUIRED for authentication to work! +AUTHENTIK_CLIENT_ID=your_client_id_here + +# OAuth Client Secret +# From Authentik: Applications → Providers → Your Provider +# REQUIRED for authentication to work! +# WARNING: Keep this secret! Never share or commit this value! +AUTHENTIK_CLIENT_SECRET=your_client_secret_here + +# OAuth Issuer URL +# From Authentik: Applications → Providers → Your Provider → OpenID Configuration +# Format: https://auth.yourdomain.com/application/o/your-app-slug/ +# IMPORTANT: Must end with a trailing slash (/) +# Development: http://localhost:9000/application/o/feuerwehr-dashboard/ +# Production: https://auth.yourdomain.com/application/o/feuerwehr-dashboard/ +AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr-dashboard/ + +# OAuth Redirect URI +# The URL where Authentik will redirect after successful authentication +# Must match EXACTLY what you configured in Authentik +# Development: http://localhost:5173/auth/callback +# Production: https://dashboard.yourdomain.com/auth/callback +AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback + +# OAuth Scopes (optional, has defaults) +# Default: openid profile email +# AUTHENTIK_SCOPES=openid profile email + +# ============================================================================ +# LOGGING CONFIGURATION (Optional) +# ============================================================================ + +# Log level +# Options: error | warn | info | debug +# Production: info or warn +# Development: debug +# LOG_LEVEL=info + +# Log file path (optional) +# Default: logs/app.log +# LOG_FILE_PATH=logs/app.log + +# ============================================================================ +# RATE LIMITING CONFIGURATION (Optional) +# ============================================================================ + +# Rate limit window in milliseconds +# Default: 900000 (15 minutes) +# RATE_LIMIT_WINDOW_MS=900000 + +# Maximum requests per window +# Default: 100 +# RATE_LIMIT_MAX=100 + +# ============================================================================ +# DEVELOPMENT OVERRIDES +# ============================================================================ +# Uncomment these for local development outside Docker + +# Development database connection (when running backend locally) +# DATABASE_URL=postgresql://dev_user:dev_password@localhost:5432/feuerwehr_dev + +# Development Authentik configuration +# AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr-dashboard/ +# AUTHENTIK_REDIRECT_URI=http://localhost:5173/auth/callback + +# Development CORS (allow Vite dev server) +# CORS_ORIGIN=http://localhost:5173 + +# Development API URL (for frontend .env) +# VITE_API_URL=http://localhost:3000 + +# ============================================================================ +# EXAMPLE: COMPLETE DEVELOPMENT CONFIGURATION +# ============================================================================ +# +# POSTGRES_DB=feuerwehr_dev +# POSTGRES_USER=dev_user +# POSTGRES_PASSWORD=dev_password +# POSTGRES_PORT=5432 +# BACKEND_PORT=3000 +# NODE_ENV=development +# JWT_SECRET=dev_secret_do_not_use_in_production +# CORS_ORIGIN=http://localhost:5173 +# FRONTEND_PORT=80 +# VITE_API_URL=http://localhost:3000 +# AUTHENTIK_CLIENT_ID=dev_client_id +# AUTHENTIK_CLIENT_SECRET=dev_client_secret +# AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr-dashboard/ +# AUTHENTIK_REDIRECT_URI=http://localhost:5173/auth/callback +# LOG_LEVEL=debug +# +# ============================================================================ + +# ============================================================================ +# EXAMPLE: COMPLETE PRODUCTION CONFIGURATION +# ============================================================================ +# +# POSTGRES_DB=feuerwehr_prod +# POSTGRES_USER=prod_user +# POSTGRES_PASSWORD= +# POSTGRES_PORT=5432 +# BACKEND_PORT=3000 +# NODE_ENV=production +# JWT_SECRET= +# CORS_ORIGIN=https://dashboard.yourdomain.com +# FRONTEND_PORT=80 +# VITE_API_URL=https://api.yourdomain.com +# AUTHENTIK_CLIENT_ID= +# AUTHENTIK_CLIENT_SECRET= +# AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr-dashboard/ +# AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback +# LOG_LEVEL=info +# +# ============================================================================ + +# ============================================================================ +# QUICK SETUP GUIDE +# ============================================================================ +# +# 1. Copy this file: +# cp .env.example .env +# +# 2. Generate secure secrets: +# JWT_SECRET=$(openssl rand -base64 32) +# POSTGRES_PASSWORD=$(openssl rand -base64 24) +# +# 3. Configure Authentik: +# - Follow AUTHENTIK_SETUP.md +# - Copy Client ID and Client Secret +# - Set correct redirect URIs +# +# 4. Update URLs: +# - Replace yourdomain.com with your actual domain +# - Ensure CORS_ORIGIN matches frontend URL +# - Ensure VITE_API_URL is accessible from browser +# +# 5. Secure the file: +# chmod 600 .env +# +# 6. Deploy: +# make prod +# +# ============================================================================ + +# ============================================================================ +# TROUBLESHOOTING +# ============================================================================ +# +# - CORS errors: Ensure CORS_ORIGIN exactly matches frontend URL +# - Auth errors: Verify all AUTHENTIK_* variables are correct +# - Database errors: Check POSTGRES_* credentials match docker-compose.yml +# - Token errors: Ensure JWT_SECRET is set and not changed +# - Redirect errors: AUTHENTIK_REDIRECT_URI must match Authentik exactly +# +# For more help, see: +# - README.md - General troubleshooting +# - DEPLOYMENT.md - Production deployment +# - AUTHENTIK_SETUP.md - Authentik configuration +# - DEVELOPMENT.md - Development setup +# +# ============================================================================ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38949b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Environment variables +.env +.env.local +.env.*.local + +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Build outputs +dist/ +build/ +out/ + +# Database +*.sqlite +*.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +logs/ +*.log + +# Docker +docker-compose.override.yml + +# Testing +coverage/ +.nyc_output/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# OS +Thumbs.db +.Spotlight-V100 +.Trashes diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..0c30e1d --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,715 @@ +# API Documentation + +Complete REST API documentation for the Feuerwehr Dashboard backend. + +## Table of Contents + +- [Base URL](#base-url) +- [Authentication](#authentication) +- [Rate Limiting](#rate-limiting) +- [Response Format](#response-format) +- [Error Codes](#error-codes) +- [Health Check](#health-check) +- [Authentication Endpoints](#authentication-endpoints) +- [User Endpoints](#user-endpoints) +- [Request Examples](#request-examples) + +## Base URL + +### Development +``` +http://localhost:3000 +``` + +### Production +``` +https://api.yourdomain.com +``` + +## Authentication + +The API uses JWT (JSON Web Token) bearer authentication for protected endpoints. + +### Authentication Header Format + +```http +Authorization: Bearer +``` + +### How to Get Tokens + +1. User authenticates via Authentik OAuth flow +2. Frontend receives authorization code +3. Frontend sends code to `/api/auth/callback` +4. Backend returns `accessToken` and `refreshToken` +5. Frontend includes `accessToken` in subsequent requests + +### Token Expiration + +- **Access Token**: 1 hour (3600 seconds) +- **Refresh Token**: 24 hours (86400 seconds) + +Use `/api/auth/refresh` endpoint to get a new access token before it expires. + +## Rate Limiting + +All `/api/*` endpoints are rate-limited to prevent abuse. + +### Limits + +- **Window**: 15 minutes +- **Max Requests**: 100 requests per IP + +### Rate Limit Headers + +```http +RateLimit-Limit: 100 +RateLimit-Remaining: 95 +RateLimit-Reset: 1645564800 +``` + +### Rate Limit Exceeded Response + +```http +HTTP/1.1 429 Too Many Requests +``` + +```json +{ + "success": false, + "message": "Too many requests from this IP, please try again later." +} +``` + +## Response Format + +All API responses follow a consistent format: + +### Success Response + +```json +{ + "success": true, + "message": "Operation completed successfully", + "data": { + // Response data here + } +} +``` + +### Error Response + +```json +{ + "success": false, + "message": "Error description", + "error": "Optional error details" +} +``` + +## Error Codes + +### HTTP Status Codes + +| Code | Meaning | Description | +|------|---------|-------------| +| 200 | OK | Request successful | +| 201 | Created | Resource created successfully | +| 400 | Bad Request | Invalid request parameters | +| 401 | Unauthorized | Authentication required or failed | +| 403 | Forbidden | Authenticated but not authorized | +| 404 | Not Found | Resource not found | +| 429 | Too Many Requests | Rate limit exceeded | +| 500 | Internal Server Error | Server error occurred | + +### Common Error Messages + +```json +// Missing authentication +{ + "success": false, + "message": "No token provided" +} + +// Invalid token +{ + "success": false, + "message": "Invalid or expired token" +} + +// Inactive user +{ + "success": false, + "message": "User account is inactive" +} +``` + +## Health Check + +Check if the API is running and healthy. + +### GET /health + +**Authentication**: Not required + +**Request**: +```http +GET /health HTTP/1.1 +Host: api.yourdomain.com +``` + +**Response**: +```http +HTTP/1.1 200 OK +Content-Type: application/json +``` + +```json +{ + "status": "ok", + "timestamp": "2026-02-23T10:30:00.000Z", + "uptime": 3600.5, + "environment": "production" +} +``` + +**Response Fields**: +- `status` (string): Always "ok" if server is running +- `timestamp` (string): Current server time in ISO 8601 format +- `uptime` (number): Server uptime in seconds +- `environment` (string): Current environment (development/production) + +## Authentication Endpoints + +### POST /api/auth/callback + +Handle OAuth callback and exchange authorization code for tokens. + +**Authentication**: Not required + +**Request Body**: +```json +{ + "code": "authorization_code_from_authentik" +} +``` + +**Request Example**: +```http +POST /api/auth/callback HTTP/1.1 +Host: api.yourdomain.com +Content-Type: application/json + +{ + "code": "abc123def456ghi789" +} +``` + +**Success Response**: +```http +HTTP/1.1 200 OK +Content-Type: application/json +``` + +```json +{ + "success": true, + "message": "Authentication successful", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": 1, + "email": "john.doe@feuerwehr.de", + "name": "John Doe", + "preferredUsername": "john.doe", + "givenName": "John", + "familyName": "Doe", + "profilePictureUrl": "https://auth.example.com/media/avatars/john.jpg", + "isActive": true + } + } +} +``` + +**Error Responses**: + +```http +HTTP/1.1 400 Bad Request +``` +```json +{ + "success": false, + "message": "Authorization code is required" +} +``` + +```http +HTTP/1.1 403 Forbidden +``` +```json +{ + "success": false, + "message": "User account is inactive" +} +``` + +```http +HTTP/1.1 500 Internal Server Error +``` +```json +{ + "success": false, + "message": "Authentication failed" +} +``` + +**Response Fields**: +- `accessToken` (string): JWT access token for API authentication +- `refreshToken` (string): JWT refresh token for getting new access tokens +- `user` (object): User profile information + - `id` (number): User database ID + - `email` (string): User email address + - `name` (string): Full name + - `preferredUsername` (string): Preferred username + - `givenName` (string): First name + - `familyName` (string): Last name + - `profilePictureUrl` (string): URL to profile picture + - `isActive` (boolean): Whether user account is active + +--- + +### POST /api/auth/refresh + +Refresh an expired access token using a refresh token. + +**Authentication**: Not required (uses refresh token) + +**Request Body**: +```json +{ + "refreshToken": "your_refresh_token" +} +``` + +**Request Example**: +```http +POST /api/auth/refresh HTTP/1.1 +Host: api.yourdomain.com +Content-Type: application/json + +{ + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**Success Response**: +```http +HTTP/1.1 200 OK +Content-Type: application/json +``` + +```json +{ + "success": true, + "message": "Token refreshed successfully", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } +} +``` + +**Error Responses**: + +```http +HTTP/1.1 400 Bad Request +``` +```json +{ + "success": false, + "message": "Refresh token is required" +} +``` + +```http +HTTP/1.1 401 Unauthorized +``` +```json +{ + "success": false, + "message": "Invalid refresh token" +} +``` + +```http +HTTP/1.1 403 Forbidden +``` +```json +{ + "success": false, + "message": "User account is inactive" +} +``` + +**Response Fields**: +- `accessToken` (string): New JWT access token + +--- + +### POST /api/auth/logout + +Logout the current user. + +**Authentication**: Optional (for logging purposes) + +**Request Headers**: +```http +Authorization: Bearer +``` + +**Request Example**: +```http +POST /api/auth/logout HTTP/1.1 +Host: api.yourdomain.com +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**Success Response**: +```http +HTTP/1.1 200 OK +Content-Type: application/json +``` + +```json +{ + "success": true, + "message": "Logout successful" +} +``` + +**Note**: Since this API uses stateless JWT authentication, logout is primarily handled client-side by discarding the tokens. This endpoint exists for audit logging purposes. + +--- + +## User Endpoints + +### GET /api/user/me + +Get the currently authenticated user's profile. + +**Authentication**: Required + +**Request Headers**: +```http +Authorization: Bearer +``` + +**Request Example**: +```http +GET /api/user/me HTTP/1.1 +Host: api.yourdomain.com +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**Success Response**: +```http +HTTP/1.1 200 OK +Content-Type: application/json +``` + +```json +{ + "success": true, + "data": { + "id": 1, + "email": "john.doe@feuerwehr.de", + "name": "John Doe", + "preferredUsername": "john.doe", + "givenName": "John", + "familyName": "Doe", + "profilePictureUrl": "https://auth.example.com/media/avatars/john.jpg", + "isActive": true, + "lastLoginAt": "2026-02-23T10:30:00.000Z", + "createdAt": "2026-01-01T12:00:00.000Z" + } +} +``` + +**Error Responses**: + +```http +HTTP/1.1 401 Unauthorized +``` +```json +{ + "success": false, + "message": "Not authenticated" +} +``` + +```http +HTTP/1.1 404 Not Found +``` +```json +{ + "success": false, + "message": "User not found" +} +``` + +**Response Fields**: +- `id` (number): User database ID +- `email` (string): User email address +- `name` (string): Full name +- `preferredUsername` (string): Preferred username +- `givenName` (string): First name +- `familyName` (string): Last name +- `profilePictureUrl` (string): URL to profile picture +- `isActive` (boolean): Whether user account is active +- `lastLoginAt` (string): Last login timestamp (ISO 8601) +- `createdAt` (string): Account creation timestamp (ISO 8601) + +--- + +## Request Examples + +### Full Authentication Flow Example + +#### Step 1: User Clicks Login + +Frontend redirects to Authentik: +```javascript +const authentikAuthUrl = `https://auth.yourdomain.com/application/o/authorize/`; +const params = new URLSearchParams({ + client_id: 'your_client_id', + redirect_uri: 'https://dashboard.yourdomain.com/auth/callback', + response_type: 'code', + scope: 'openid profile email' +}); + +window.location.href = `${authentikAuthUrl}?${params}`; +``` + +#### Step 2: Authentik Redirects Back + +After authentication, Authentik redirects to: +``` +https://dashboard.yourdomain.com/auth/callback?code=abc123def456 +``` + +#### Step 3: Exchange Code for Tokens + +```bash +curl -X POST https://api.yourdomain.com/api/auth/callback \ + -H "Content-Type: application/json" \ + -d '{ + "code": "abc123def456" + }' +``` + +Response: +```json +{ + "success": true, + "message": "Authentication successful", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImVtYWlsIjoiam9obkBleGFtcGxlLmNvbSIsImlhdCI6MTcwODY5MTQwMCwiZXhwIjoxNzA4Njk1MDAwfQ.signature", + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImVtYWlsIjoiam9obkBleGFtcGxlLmNvbSIsImlhdCI6MTcwODY5MTQwMCwiZXhwIjoxNzA4Nzc3ODAwfQ.signature", + "user": { + "id": 1, + "email": "john.doe@feuerwehr.de", + "name": "John Doe", + "preferredUsername": "john.doe", + "givenName": "John", + "familyName": "Doe", + "profilePictureUrl": null, + "isActive": true + } + } +} +``` + +#### Step 4: Access Protected Resources + +```bash +curl -X GET https://api.yourdomain.com/api/user/me \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +#### Step 5: Refresh Token When Expired + +```bash +curl -X POST https://api.yourdomain.com/api/auth/refresh \ + -H "Content-Type: application/json" \ + -d '{ + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }' +``` + +### JavaScript/TypeScript Examples + +#### Using Axios + +```typescript +import axios from 'axios'; + +const API_URL = 'https://api.yourdomain.com'; + +// Create axios instance with auth +const api = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Add auth token to requests +api.interceptors.request.use((config) => { + const token = localStorage.getItem('accessToken'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Handle authentication +export const handleCallback = async (code: string) => { + const response = await api.post('/api/auth/callback', { code }); + const { accessToken, refreshToken, user } = response.data.data; + + // Store tokens + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + + return user; +}; + +// Refresh token +export const refreshAccessToken = async () => { + const refreshToken = localStorage.getItem('refreshToken'); + const response = await api.post('/api/auth/refresh', { refreshToken }); + const { accessToken } = response.data.data; + + localStorage.setItem('accessToken', accessToken); + return accessToken; +}; + +// Get current user +export const getCurrentUser = async () => { + const response = await api.get('/api/user/me'); + return response.data.data; +}; + +// Logout +export const logout = async () => { + await api.post('/api/auth/logout'); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); +}; +``` + +### cURL Examples + +#### Health Check +```bash +curl -X GET https://api.yourdomain.com/health +``` + +#### Login Callback +```bash +curl -X POST https://api.yourdomain.com/api/auth/callback \ + -H "Content-Type: application/json" \ + -d '{"code":"your_auth_code"}' +``` + +#### Get Current User +```bash +curl -X GET https://api.yourdomain.com/api/user/me \ + -H "Authorization: Bearer your_access_token" +``` + +#### Refresh Token +```bash +curl -X POST https://api.yourdomain.com/api/auth/refresh \ + -H "Content-Type: application/json" \ + -d '{"refreshToken":"your_refresh_token"}' +``` + +#### Logout +```bash +curl -X POST https://api.yourdomain.com/api/auth/logout \ + -H "Authorization: Bearer your_access_token" +``` + +## Security Considerations + +### HTTPS Required in Production + +Always use HTTPS for API requests in production to protect tokens and sensitive data. + +### Token Storage + +- **Access Token**: Store in memory or sessionStorage (more secure) +- **Refresh Token**: Can be stored in httpOnly cookie or localStorage +- **Never**: Store tokens in unencrypted cookies or URL parameters + +### CORS Configuration + +The API is configured to only accept requests from allowed origins: + +```javascript +// Backend CORS configuration +cors({ + origin: process.env.CORS_ORIGIN, // e.g., https://dashboard.yourdomain.com + credentials: true +}) +``` + +Ensure `CORS_ORIGIN` environment variable matches your frontend URL exactly. + +### Rate Limiting + +Respect rate limits to avoid being temporarily blocked. Implement exponential backoff for failed requests. + +## Additional Notes + +### Timestamps + +All timestamps are in ISO 8601 format with UTC timezone: +``` +2026-02-23T10:30:00.000Z +``` + +### JSON Format + +All request bodies and responses use JSON format with `Content-Type: application/json`. + +### Versioning + +API versioning is currently not implemented. All endpoints are under `/api/` prefix. + +Future versions may use: +- `/api/v1/` for version 1 +- `/api/v2/` for version 2 + +## Support + +For API support: + +1. Check this documentation +2. Review [DEVELOPMENT.md](DEVELOPMENT.md) for debugging tips +3. Check backend logs for detailed error messages +4. Consult [ARCHITECTURE.md](ARCHITECTURE.md) for system design +5. Create an issue with detailed request/response information + +## Changelog + +### Version 1.0.0 (2026-02-23) + +Initial API release: +- OAuth 2.0 authentication with Authentik +- JWT-based session management +- User profile endpoints +- Health check endpoint +- Rate limiting +- Security headers diff --git a/AUTHENTIK_SETUP.md b/AUTHENTIK_SETUP.md new file mode 100644 index 0000000..67f054e --- /dev/null +++ b/AUTHENTIK_SETUP.md @@ -0,0 +1,609 @@ +# Authentik Setup Guide + +This guide walks you through configuring Authentik for OAuth 2.0 / OpenID Connect authentication with the Feuerwehr Dashboard. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Authentik Installation](#authentik-installation) +- [Creating the Application](#creating-the-application) +- [Provider Configuration](#provider-configuration) +- [Redirect URIs Setup](#redirect-uris-setup) +- [Scopes Configuration](#scopes-configuration) +- [Getting Client Credentials](#getting-client-credentials) +- [Testing Authentication](#testing-authentication) +- [Common Issues](#common-issues) +- [Advanced Configuration](#advanced-configuration) + +## Prerequisites + +Before you begin, you need: + +- An Authentik instance (self-hosted or cloud) +- Admin access to Authentik +- Your Feuerwehr Dashboard URL (e.g., `https://dashboard.yourdomain.com`) +- Your backend API URL (e.g., `https://api.yourdomain.com`) + +## Authentik Installation + +If you don't have Authentik yet, here's a quick setup guide. + +### Option 1: Docker Compose (Recommended) + +Create `docker-compose.yml`: + +```yaml +version: "3.8" + +services: + postgresql: + image: postgres:16-alpine + restart: unless-stopped + volumes: + - database:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: ${PG_PASS:?database password required} + POSTGRES_USER: ${PG_USER:-authentik} + POSTGRES_DB: ${PG_DB:-authentik} + env_file: + - .env + + redis: + image: redis:alpine + restart: unless-stopped + + server: + image: ghcr.io/goauthentik/server:2024.2 + restart: unless-stopped + command: server + environment: + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_REDIS__HOST: redis + ports: + - "${AUTHENTIK_PORT_HTTP:-9000}:9000" + - "${AUTHENTIK_PORT_HTTPS:-9443}:9443" + depends_on: + - postgresql + - redis + + worker: + image: ghcr.io/goauthentik/server:2024.2 + restart: unless-stopped + command: worker + environment: + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_REDIS__HOST: redis + depends_on: + - postgresql + - redis + +volumes: + database: + driver: local +``` + +Create `.env`: + +```bash +PG_PASS= +AUTHENTIK_SECRET_KEY= +AUTHENTIK_PORT_HTTP=9000 +AUTHENTIK_PORT_HTTPS=9443 +``` + +Start Authentik: + +```bash +docker-compose up -d +``` + +Access Authentik at `http://localhost:9000` and complete the initial setup wizard. + +### Option 2: Kubernetes + +See [Authentik Kubernetes Documentation](https://goauthentik.io/docs/installation/kubernetes) for Helm charts. + +## Creating the Application + +### Step 1: Access Authentik Admin Interface + +1. Log in to your Authentik instance +2. Navigate to **Admin Interface** (gear icon in top right) + +### Step 2: Create Provider + +1. In the admin panel, go to **Applications** → **Providers** +2. Click **Create** button +3. Select **OAuth2/OpenID Provider** + +Configure the provider: + +``` +Name: Feuerwehr Dashboard Provider +Authorization Flow: default-provider-authorization-implicit-consent +Protocol Settings: +``` + +**Client Type**: +- Select: `Confidential` + +**Client ID**: +- Auto-generated (you'll copy this later) +- Or set custom: `feuerwehr-dashboard` + +**Client Secret**: +- Auto-generated (you'll copy this later) + +**Redirect URIs**: +``` +http://localhost:5173/auth/callback +http://localhost/auth/callback +https://dashboard.yourdomain.com/auth/callback +``` + +Add one URI per line. Include all environments (development, staging, production). + +**Signing Key**: +- Select: `authentik Self-signed Certificate` (default) + +**Token Validity**: +``` +Access token validity: 3600 (1 hour) +Refresh token validity: 86400 (24 hours) +``` + +Click **Create** to save the provider. + +### Step 3: Create Application + +1. Go to **Applications** → **Applications** +2. Click **Create** button + +Configure the application: + +``` +Name: Feuerwehr Dashboard +Slug: feuerwehr-dashboard +Provider: Feuerwehr Dashboard Provider (select from dropdown) +Launch URL: https://dashboard.yourdomain.com +``` + +**UI Settings** (optional): +``` +Icon: (upload fire department logo) +Publisher: Your Fire Department Name +Description: Fire department operations dashboard +``` + +Click **Create** to save. + +### Step 4: Configure Scopes + +1. Go back to **Providers** → **Feuerwehr Dashboard Provider** +2. Scroll to **Advanced protocol settings** +3. Click **Edit** on **Scopes** + +Ensure the following scopes are selected: + +- [x] `openid` - Required for OpenID Connect +- [x] `profile` - User profile information +- [x] `email` - User email address + +Optional scopes: +- [ ] `offline_access` - For refresh tokens (recommended) + +Click **Update**. + +## Provider Configuration + +### Step 5: Configure Advanced Settings + +Edit your provider and configure these advanced settings: + +**Token Settings**: +``` +Include claims in ID token: Yes +Access token validity: 3600 seconds (1 hour) +Refresh token validity: 86400 seconds (24 hours) +``` + +**Subject Mode**: +``` +Based on the User's hashed ID +``` + +This ensures consistent user IDs. + +**Signing Algorithm**: +``` +RS256 (default) +``` + +### Step 6: Configure Claims + +Ensure the following claims are mapped: + +Standard claims (should be automatic): +- `sub` - Subject (user ID) +- `email` - User email +- `name` - Full name +- `preferred_username` - Username +- `given_name` - First name +- `family_name` - Last name + +These are automatically included with the `profile` and `email` scopes. + +## Redirect URIs Setup + +Your redirect URIs must match exactly between Authentik and your application. + +### Development Environment + +``` +http://localhost:5173/auth/callback +``` + +This is the Vite dev server URL. + +### Production Environment + +``` +https://dashboard.yourdomain.com/auth/callback +``` + +Replace `yourdomain.com` with your actual domain. + +### Docker Local Testing + +``` +http://localhost/auth/callback +http://localhost:80/auth/callback +``` + +For testing the production Docker build locally. + +### Important Notes + +- URLs are **case-sensitive** +- Must include protocol (`http://` or `https://`) +- No trailing slashes +- Port numbers must match if specified + +## Scopes Configuration + +### Required Scopes + +Your application requires these scopes: + +1. **openid** - Identifies this as an OpenID Connect request +2. **profile** - Provides basic profile information +3. **email** - Provides user email address + +### Requesting Scopes + +The dashboard automatically requests these scopes in the OAuth flow. + +Backend configuration (`backend/src/config/oauth.ts`): +```typescript +scopes: ['openid', 'profile', 'email'] +``` + +Frontend login flow (`frontend/src/services/authService.ts`): +```typescript +const scopes = 'openid profile email'; +``` + +## Getting Client Credentials + +### Step 7: Copy Client Credentials + +1. Go to **Applications** → **Providers** +2. Click on **Feuerwehr Dashboard Provider** +3. Find the **Client ID** and **Client Secret** + +**Client ID**: Will look like `abc123def456...` or custom `feuerwehr-dashboard` + +**Client Secret**: Click the **eye icon** to reveal, then **copy button** + +### Step 8: Get Provider URLs + +1. In the provider details, find **OpenID Configuration URL**: + ``` + https://auth.yourdomain.com/application/o/feuerwehr-dashboard/.well-known/openid-configuration + ``` + +2. Important URLs from this configuration: + - **Issuer**: `https://auth.yourdomain.com/application/o/feuerwehr-dashboard/` + - **Authorization Endpoint**: Auto-discovered + - **Token Endpoint**: Auto-discovered + - **Userinfo Endpoint**: Auto-discovered + +### Step 9: Configure Dashboard + +Update your Feuerwehr Dashboard `.env` file: + +```bash +# Authentik OAuth Configuration +AUTHENTIK_CLIENT_ID= +AUTHENTIK_CLIENT_SECRET= +AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr-dashboard/ +AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback + +# For development, use: +# AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr-dashboard/ +# AUTHENTIK_REDIRECT_URI=http://localhost:5173/auth/callback +``` + +**Important**: The `AUTHENTIK_ISSUER` must end with a trailing slash `/`. + +## Testing Authentication + +### Step 10: Test the Flow + +1. **Start your application**: + ```bash + # Development + cd backend && npm run dev + cd frontend && npm run dev + + # Or production + make prod + ``` + +2. **Open the dashboard** in your browser: + ``` + Development: http://localhost:5173 + Production: https://dashboard.yourdomain.com + ``` + +3. **Click "Login" button** + +4. **You should be redirected to Authentik** login page + +5. **Enter your Authentik credentials** + +6. **Consent screen** (if enabled): + - Review permissions + - Click "Authorize" + +7. **Redirect back to dashboard**: + - Should show your dashboard + - User profile should be loaded + - Check browser console for any errors + +### Step 11: Verify Token Exchange + +Check backend logs: + +```bash +make logs-prod +# or +cd backend && npm run dev +``` + +Look for: +``` +[INFO] OAuth callback received +[INFO] Token exchange successful +[INFO] User authenticated: +``` + +### Step 12: Test User Profile + +In the dashboard: +1. Navigate to Profile page +2. Verify your information is displayed: + - Username + - Email + - Name + +### Step 13: Test Logout + +1. Click "Logout" button +2. Should redirect to login page +3. Verify session is cleared +4. Attempting to access dashboard should redirect to login + +## Common Issues + +### Issue 1: Redirect URI Mismatch + +**Error**: `redirect_uri_mismatch` or `invalid_redirect_uri` + +**Solution**: +1. Check the redirect URI in Authentik **exactly** matches your app +2. Include protocol (`http://` or `https://`) +3. Check for trailing slashes +4. Verify port numbers match + +### Issue 2: Invalid Client Credentials + +**Error**: `invalid_client` + +**Solution**: +1. Verify `AUTHENTIK_CLIENT_ID` is correct +2. Verify `AUTHENTIK_CLIENT_SECRET` is correct (no extra spaces) +3. Check if client secret was regenerated in Authentik +4. Ensure client type is "Confidential" + +### Issue 3: CORS Errors + +**Error**: CORS policy blocked request + +**Solution**: +1. Ensure `CORS_ORIGIN` in backend `.env` matches frontend URL +2. For development: `CORS_ORIGIN=http://localhost:5173` +3. For production: `CORS_ORIGIN=https://dashboard.yourdomain.com` +4. Restart backend after changing CORS settings + +### Issue 4: Token Validation Failed + +**Error**: `Invalid token` or `Token signature verification failed` + +**Solution**: +1. Verify `AUTHENTIK_ISSUER` URL is correct +2. Ensure issuer URL ends with `/` +3. Check Authentik is accessible from backend server +4. Verify JWT signing key hasn't changed + +### Issue 5: Scope Errors + +**Error**: `invalid_scope` or missing user data + +**Solution**: +1. Ensure `openid`, `profile`, `email` scopes are configured in Authentik +2. Check scopes are requested in login flow +3. Verify user has consented to scopes + +### Issue 6: User Not Created in Database + +**Error**: Authentication works but user not found + +**Solution**: +1. Check backend logs for database errors +2. Verify database migrations ran +3. Check `users` table exists: + ```bash + docker exec -it feuerwehr_db_prod psql -U prod_user -d feuerwehr_prod -c "\dt" + ``` +4. Manually run migrations if needed + +### Issue 7: Token Refresh Fails + +**Error**: Refresh token invalid or expired + +**Solution**: +1. Check token validity settings in Authentik +2. Increase refresh token validity (e.g., 86400 seconds) +3. Verify `offline_access` scope is included +4. Clear browser cookies and re-authenticate + +## Advanced Configuration + +### Customizing Login Flow + +Create custom authentication flow in Authentik: + +1. Go to **Flows & Stages** → **Flows** +2. Duplicate `default-provider-authorization-implicit-consent` +3. Add custom stages (MFA, email verification, etc.) +4. Update provider to use custom flow + +### Adding Multi-Factor Authentication + +1. Go to **Flows & Stages** → **Stages** +2. Create authenticator validation stage +3. Add to your authorization flow +4. Users will be prompted for MFA on login + +### Branding + +Customize Authentik appearance: + +1. Go to **System** → **Branding** +2. Upload logo +3. Set primary color to match fire department branding +4. Configure footer links + +### User Management + +Add users to Authentik: + +1. Go to **Directory** → **Users** +2. Click **Create** +3. Fill in user details +4. Set password +5. Assign to groups (if using RBAC) + +### Group-Based Access Control + +Implement role-based access: + +1. Create groups in **Directory** → **Groups** +2. Assign users to groups +3. In your app, read `groups` claim from token +4. Implement authorization based on groups + +Example groups: +- `feuerwehr-admin` - Full access +- `feuerwehr-operator` - Standard access +- `feuerwehr-viewer` - Read-only access + +## Testing Checklist + +After configuration, verify: + +- [ ] Login redirects to Authentik +- [ ] Successful authentication redirects back +- [ ] User profile loads correctly +- [ ] Tokens are stored securely +- [ ] Logout clears session +- [ ] Token refresh works +- [ ] Protected routes require authentication +- [ ] Invalid tokens are rejected +- [ ] CORS is properly configured +- [ ] Works on all browsers (Chrome, Firefox, Safari) + +## Configuration Reference + +### Minimum Required Settings + +**Authentik Provider**: +``` +Client Type: Confidential +Client ID: +Client Secret: +Redirect URIs: https://dashboard.yourdomain.com/auth/callback +Scopes: openid, profile, email +Access Token Validity: 3600 +Refresh Token Validity: 86400 +``` + +**Dashboard .env**: +```bash +AUTHENTIK_CLIENT_ID= +AUTHENTIK_CLIENT_SECRET= +AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr-dashboard/ +AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback +``` + +## Security Best Practices + +1. **Use HTTPS in production** - Never use HTTP for OAuth flows +2. **Keep secrets secret** - Never commit credentials to Git +3. **Rotate secrets regularly** - Change client secrets periodically +4. **Use strong passwords** - For Authentik admin and users +5. **Enable MFA** - Require multi-factor authentication for admin users +6. **Monitor logs** - Watch for failed login attempts +7. **Limit token validity** - Use short-lived access tokens +8. **Validate redirect URIs** - Only allow known URLs + +## Additional Resources + +- [Authentik Documentation](https://goauthentik.io/docs/) +- [OAuth 2.0 Specification](https://oauth.net/2/) +- [OpenID Connect](https://openid.net/connect/) +- [Authentik Community Forum](https://github.com/goauthentik/authentik/discussions) + +## Support + +If you encounter issues: + +1. Check Authentik logs: `docker-compose logs -f server` +2. Check dashboard backend logs: `make logs-prod` +3. Review browser console for errors +4. Consult [Common Issues](#common-issues) section +5. Search Authentik GitHub issues +6. Create detailed issue report + +--- + +**Next Steps**: After completing Authentik setup, proceed to [DEPLOYMENT.md](DEPLOYMENT.md) for production deployment. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..b4f00c8 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,704 @@ +# Production Deployment Guide + +This guide walks you through deploying the Feuerwehr Dashboard in a production environment. + +## Table of Contents + +- [Server Requirements](#server-requirements) +- [Pre-Deployment Checklist](#pre-deployment-checklist) +- [Deployment Steps](#deployment-steps) +- [Authentik Configuration](#authentik-configuration) +- [SSL/HTTPS Setup](#ssl-https-setup) +- [Database Management](#database-management) +- [Monitoring Setup](#monitoring-setup) +- [Update Procedures](#update-procedures) +- [Rollback Procedures](#rollback-procedures) +- [Performance Tuning](#performance-tuning) +- [Security Hardening](#security-hardening) + +## Server Requirements + +### Minimum Requirements + +- **CPU**: 2 cores +- **RAM**: 2 GB (4 GB recommended) +- **Storage**: 20 GB SSD +- **OS**: Ubuntu 20.04+ / Debian 11+ / CentOS 8+ / RHEL 8+ +- **Docker**: 20.10+ +- **Docker Compose**: 2.0+ + +### Network Requirements + +Open the following ports: + +- **80** - HTTP (will redirect to HTTPS) +- **443** - HTTPS (if using SSL) +- **3000** - Backend API (can be internal only) +- **5432** - PostgreSQL (should be internal only) + +### Recommended Production Specs + +- **CPU**: 4 cores +- **RAM**: 8 GB +- **Storage**: 50 GB SSD with RAID for database +- **Network**: 100 Mbps minimum + +## Pre-Deployment Checklist + +Before deploying to production: + +- [ ] Server meets minimum requirements +- [ ] Docker and Docker Compose installed +- [ ] Domain name configured (DNS A record pointing to server) +- [ ] Authentik instance available and accessible +- [ ] SSL certificates obtained (Let's Encrypt or commercial) +- [ ] Firewall configured +- [ ] Backup strategy planned +- [ ] Monitoring solution chosen +- [ ] All secrets and credentials prepared + +## Deployment Steps + +### Step 1: Server Preparation + +Update system packages: + +```bash +# Ubuntu/Debian +sudo apt update && sudo apt upgrade -y + +# CentOS/RHEL +sudo yum update -y +``` + +Install Docker: + +```bash +# Ubuntu/Debian +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER + +# CentOS/RHEL +sudo yum install -y docker +sudo systemctl start docker +sudo systemctl enable docker +``` + +Install Docker Compose: + +```bash +sudo curl -L "https://github.com/docker/compose/releases/download/v2.24.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose +docker-compose --version +``` + +Configure firewall: + +```bash +# Ubuntu/Debian (UFW) +sudo ufw allow 22/tcp +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable + +# CentOS/RHEL (firewalld) +sudo firewall-cmd --permanent --add-service=http +sudo firewall-cmd --permanent --add-service=https +sudo firewall-cmd --permanent --add-service=ssh +sudo firewall-cmd --reload +``` + +### Step 2: Clone Repository + +```bash +cd /opt +sudo git clone feuerwehr_dashboard +cd feuerwehr_dashboard +sudo chown -R $USER:$USER . +``` + +### Step 3: Configure Environment + +Generate secure secrets: + +```bash +# Generate JWT secret (save this!) +openssl rand -base64 32 + +# Generate strong database password (save this!) +openssl rand -base64 24 +``` + +Create `.env` file: + +```bash +cp .env.example .env +nano .env +``` + +Configure the following critical variables: + +```bash +# Database - Use strong passwords! +POSTGRES_DB=feuerwehr_prod +POSTGRES_USER=prod_user +POSTGRES_PASSWORD= +POSTGRES_PORT=5432 + +# Backend +BACKEND_PORT=3000 +NODE_ENV=production + +# JWT - Use generated secret! +JWT_SECRET= + +# CORS - Set to your domain! +CORS_ORIGIN=https://dashboard.yourdomain.com + +# Frontend +FRONTEND_PORT=80 + +# API URL - Set to your backend URL +VITE_API_URL=https://api.yourdomain.com + +# Authentik OAuth (from Authentik setup) +AUTHENTIK_CLIENT_ID= +AUTHENTIK_CLIENT_SECRET= +AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr/ +AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback +``` + +Secure the .env file: + +```bash +chmod 600 .env +``` + +### Step 4: Initial Deployment + +Deploy the application: + +```bash +make prod +# or +./deploy.sh production +``` + +Verify all services are running: + +```bash +docker-compose ps +``` + +Expected output: +``` +NAME STATUS PORTS +feuerwehr_backend_prod Up (healthy) 0.0.0.0:3000->3000/tcp +feuerwehr_db_prod Up (healthy) 0.0.0.0:5432->5432/tcp +feuerwehr_frontend_prod Up (healthy) 0.0.0.0:80->80/tcp +``` + +Check logs for errors: + +```bash +make logs-prod +``` + +### Step 5: Database Initialization + +The database will be automatically initialized on first start using the migration scripts in `backend/src/database/migrations/`. + +Verify database tables: + +```bash +docker exec -it feuerwehr_db_prod psql -U prod_user -d feuerwehr_prod -c "\dt" +``` + +### Step 6: Test the Deployment + +Test health endpoints: + +```bash +# Backend health check +curl http://localhost:3000/health + +# Frontend availability +curl http://localhost:80 +``` + +Test authentication flow: +1. Open browser to `http://localhost` (or your domain) +2. Click login +3. Authenticate with Authentik +4. Verify redirect back to dashboard +5. Verify user profile loads + +## Authentik Configuration + +See [AUTHENTIK_SETUP.md](AUTHENTIK_SETUP.md) for complete Authentik configuration. + +Key points for production: + +1. **Use HTTPS URLs** for all redirect URIs +2. **Configure proper scopes**: `openid`, `profile`, `email` +3. **Set token expiration** appropriately (e.g., 3600s for access, 86400s for refresh) +4. **Enable required claims** in the provider +5. **Test the flow** thoroughly before going live + +## SSL/HTTPS Setup + +### Option 1: Using Caddy (Recommended) + +Create `Caddyfile`: + +```caddy +dashboard.yourdomain.com { + reverse_proxy localhost:80 + encode gzip + + header { + Strict-Transport-Security "max-age=31536000;" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + } +} + +api.yourdomain.com { + reverse_proxy localhost:3000 + encode gzip +} +``` + +Run Caddy: + +```bash +docker run -d \ + --name caddy \ + --network host \ + -v $PWD/Caddyfile:/etc/caddy/Caddyfile \ + -v caddy_data:/data \ + -v caddy_config:/config \ + --restart unless-stopped \ + caddy:latest +``` + +Caddy will automatically obtain and renew SSL certificates from Let's Encrypt. + +### Option 2: Using Nginx with Certbot + +Install Nginx and Certbot: + +```bash +sudo apt install nginx certbot python3-certbot-nginx -y +``` + +Create Nginx configuration (`/etc/nginx/sites-available/feuerwehr`): + +```nginx +server { + listen 80; + server_name dashboard.yourdomain.com; + + location / { + proxy_pass http://localhost:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 80; + server_name api.yourdomain.com; + + location / { + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Enable and obtain SSL: + +```bash +sudo ln -s /etc/nginx/sites-available/feuerwehr /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +sudo certbot --nginx -d dashboard.yourdomain.com -d api.yourdomain.com +``` + +### Option 3: Using Docker with Traefik + +See community guides for Traefik integration. + +## Database Management + +### Backup Procedures + +Create automated backup script (`/opt/backup-feuerwehr-db.sh`): + +```bash +#!/bin/bash +BACKUP_DIR="/opt/backups/feuerwehr" +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$BACKUP_DIR/feuerwehr_backup_$DATE.sql.gz" + +mkdir -p $BACKUP_DIR + +docker exec feuerwehr_db_prod pg_dump -U prod_user feuerwehr_prod | gzip > $BACKUP_FILE + +# Keep only last 30 days of backups +find $BACKUP_DIR -name "*.sql.gz" -mtime +30 -delete + +echo "Backup completed: $BACKUP_FILE" +``` + +Make executable and schedule: + +```bash +chmod +x /opt/backup-feuerwehr-db.sh + +# Add to crontab (daily at 2 AM) +crontab -e +0 2 * * * /opt/backup-feuerwehr-db.sh >> /var/log/feuerwehr-backup.log 2>&1 +``` + +### Restore Procedures + +Restore from backup: + +```bash +# Stop backend service +docker-compose stop backend + +# Restore database +gunzip -c /opt/backups/feuerwehr/feuerwehr_backup_20260223_020000.sql.gz | \ + docker exec -i feuerwehr_db_prod psql -U prod_user -d feuerwehr_prod + +# Restart backend +docker-compose start backend +``` + +### Database Maintenance + +Regular vacuum and analyze: + +```bash +docker exec feuerwehr_db_prod psql -U prod_user -d feuerwehr_prod -c "VACUUM ANALYZE;" +``` + +## Monitoring Setup + +### Basic Health Monitoring + +Create monitoring script (`/opt/monitor-feuerwehr.sh`): + +```bash +#!/bin/bash + +# Check backend health +BACKEND_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health) +if [ "$BACKEND_STATUS" != "200" ]; then + echo "Backend health check failed: $BACKEND_STATUS" + # Send alert (email, Slack, etc.) +fi + +# Check database +DB_STATUS=$(docker exec feuerwehr_db_prod pg_isready -U prod_user) +if [ $? -ne 0 ]; then + echo "Database health check failed" + # Send alert +fi + +# Check container status +CONTAINERS=$(docker-compose ps --services --filter "status=running" | wc -l) +if [ "$CONTAINERS" -lt 3 ]; then + echo "Not all containers are running" + # Send alert +fi +``` + +Schedule monitoring: + +```bash +chmod +x /opt/monitor-feuerwehr.sh +crontab -e +*/5 * * * * /opt/monitor-feuerwehr.sh >> /var/log/feuerwehr-monitor.log 2>&1 +``` + +### Recommended Monitoring Tools + +- **Prometheus + Grafana** - Metrics and dashboards +- **Loki** - Log aggregation +- **Uptime Kuma** - Simple uptime monitoring +- **Portainer** - Docker container management + +## Update Procedures + +### Standard Update Process + +1. **Backup everything**: + ```bash + /opt/backup-feuerwehr-db.sh + docker-compose down + tar -czf /opt/backups/feuerwehr_config_$(date +%Y%m%d).tar.gz .env docker-compose.yml + ``` + +2. **Pull latest changes**: + ```bash + git pull origin main + ``` + +3. **Review changes**: + ```bash + git log --oneline -10 + git diff HEAD~1 .env.example + ``` + +4. **Update environment** if needed: + ```bash + # Compare .env.example with your .env + diff .env.example .env + # Add any new required variables + ``` + +5. **Rebuild and deploy**: + ```bash + make rebuild + # or + ./deploy.sh rebuild + ``` + +6. **Verify update**: + ```bash + docker-compose ps + make logs-prod + # Test critical functionality + ``` + +### Zero-Downtime Updates + +For zero-downtime updates, use blue-green deployment: + +1. Set up second environment +2. Deploy new version to green +3. Test green environment +4. Switch traffic (using load balancer) +5. Shut down blue environment + +## Rollback Procedures + +### Quick Rollback + +If issues arise after update: + +1. **Stop current version**: + ```bash + docker-compose down + ``` + +2. **Restore previous version**: + ```bash + git log --oneline -5 # Find previous commit + git checkout + ``` + +3. **Restore database** if schema changed: + ```bash + gunzip -c /opt/backups/feuerwehr/feuerwehr_backup_.sql.gz | \ + docker exec -i feuerwehr_db_prod psql -U prod_user -d feuerwehr_prod + ``` + +4. **Redeploy**: + ```bash + make prod + ``` + +### Tagged Releases + +Use Git tags for stable releases: + +```bash +# Tag a release +git tag -a v1.0.0 -m "Production release 1.0.0" +git push origin v1.0.0 + +# Deploy specific release +git checkout v1.0.0 +make prod +``` + +## Performance Tuning + +### Database Optimization + +Edit PostgreSQL configuration in `docker-compose.yml`: + +```yaml +postgres: + command: + - "postgres" + - "-c" + - "max_connections=100" + - "-c" + - "shared_buffers=256MB" + - "-c" + - "effective_cache_size=1GB" + - "-c" + - "work_mem=4MB" +``` + +### Backend Optimization + +Add environment variables to backend: + +```bash +# In .env +NODE_OPTIONS=--max-old-space-size=2048 +``` + +### Frontend Optimization + +Already optimized: +- Vite build optimization +- Gzip compression in Nginx +- Static asset caching + +### Docker Resource Limits + +Add resource limits to `docker-compose.yml`: + +```yaml +backend: + deploy: + resources: + limits: + cpus: '2' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M +``` + +## Security Hardening + +### Docker Security + +Run containers as non-root: + +```yaml +backend: + user: "node" + +postgres: + user: "postgres" +``` + +### Network Security + +Isolate database from external access: + +```yaml +postgres: + # Remove ports mapping for production + # ports: + # - "5432:5432" + # Database is only accessible via Docker network +``` + +### Environment Security + +Protect sensitive files: + +```bash +chmod 600 .env +chmod 600 docker-compose.yml +``` + +### Regular Security Updates + +Set up automatic security updates: + +```bash +# Ubuntu/Debian +sudo apt install unattended-upgrades +sudo dpkg-reconfigure -plow unattended-upgrades +``` + +### Security Monitoring + +- Enable Docker security scanning +- Monitor logs for suspicious activity +- Set up fail2ban for SSH protection +- Regular security audits + +## Post-Deployment + +After successful deployment: + +1. **Document your setup** - Note any custom configurations +2. **Train your team** - Ensure team knows how to use the system +3. **Set up alerts** - Configure notifications for issues +4. **Schedule maintenance** - Plan for regular updates and backups +5. **Review security** - Regular security audits +6. **Monitor performance** - Track metrics and optimize as needed + +## Troubleshooting + +### Services Won't Start + +Check logs: +```bash +docker-compose logs +``` + +Check resources: +```bash +docker stats +df -h +free -h +``` + +### SSL Certificate Issues + +Renew certificates: +```bash +sudo certbot renew +``` + +### Performance Issues + +Check resource usage: +```bash +docker stats +htop +iotop +``` + +Optimize database: +```bash +docker exec feuerwehr_db_prod psql -U prod_user -d feuerwehr_prod -c "VACUUM FULL ANALYZE;" +``` + +## Support + +For production support issues: + +1. Check logs first +2. Review this deployment guide +3. Consult [TROUBLESHOOTING](README.md#troubleshooting) section +4. Create detailed issue report +5. Contact support team + +## Additional Resources + +- [Docker Documentation](https://docs.docker.com/) +- [PostgreSQL Performance Tuning](https://wiki.postgresql.org/wiki/Performance_Optimization) +- [Node.js Production Best Practices](https://nodejs.org/en/docs/guides/nodejs-docker-webapp/) +- [Authentik Documentation](https://goauthentik.io/docs/) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..411baf2 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,828 @@ +# Development Guide + +Welcome to the Feuerwehr Dashboard development guide. This document will help you get started with developing, testing, and contributing to the project. + +## Table of Contents + +- [Developer Onboarding](#developer-onboarding) +- [Local Development Setup](#local-development-setup) +- [Project Structure](#project-structure) +- [Code Architecture](#code-architecture) +- [Development Workflow](#development-workflow) +- [Adding New Features](#adding-new-features) +- [Testing Guidelines](#testing-guidelines) +- [Debugging](#debugging) +- [Common Issues](#common-issues) +- [Best Practices](#best-practices) + +## Developer Onboarding + +### Prerequisites + +Before you begin, ensure you have: + +- **Node.js 18+** - [Download](https://nodejs.org/) +- **Docker Desktop** - [Download](https://www.docker.com/products/docker-desktop) +- **Git** - [Download](https://git-scm.com/) +- **Code Editor** - VS Code recommended +- **Authentik Access** - For testing authentication + +### Recommended VS Code Extensions + +```json +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "ms-vscode.vscode-typescript-next", + "bradlc.vscode-tailwindcss", + "ms-azuretools.vscode-docker", + "prisma.prisma" + ] +} +``` + +### First Steps + +1. **Clone the repository**: + ```bash + git clone + cd feuerwehr_dashboard + ``` + +2. **Read the documentation**: + - [README.md](README.md) - Project overview + - [ARCHITECTURE.md](ARCHITECTURE.md) - System design + - [API_DOCUMENTATION.md](API_DOCUMENTATION.md) - API reference + +3. **Set up your environment** (see below) + +4. **Run the application** locally + +5. **Make a small change** to familiarize yourself with the codebase + +## Local Development Setup + +### Option 1: Docker-Based Development (Recommended for Beginners) + +Start the development database: + +```bash +make dev +``` + +This starts only PostgreSQL, allowing you to run backend/frontend locally with hot reload. + +### Option 2: Fully Dockerized (Quick Testing) + +Run everything in Docker: + +```bash +make prod +``` + +Note: This doesn't support hot reload, so it's less ideal for active development. + +### Option 3: Native Development (Full Control) + +#### Step 1: Start Database + +```bash +make dev +``` + +Or manually: + +```bash +docker-compose -f docker-compose.dev.yml up -d +``` + +#### Step 2: Configure Backend + +Create `backend/.env`: + +```bash +cd backend +cat > .env << EOF +DATABASE_URL=postgresql://dev_user:dev_password@localhost:5432/feuerwehr_dev +JWT_SECRET=dev_secret_do_not_use_in_production +NODE_ENV=development +PORT=3000 +LOG_LEVEL=debug + +# Authentik - use your dev instance +AUTHENTIK_CLIENT_ID=your_dev_client_id +AUTHENTIK_CLIENT_SECRET=your_dev_client_secret +AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr/ +AUTHENTIK_REDIRECT_URI=http://localhost:5173/auth/callback +EOF +``` + +#### Step 3: Install Backend Dependencies + +```bash +cd backend +npm install +``` + +#### Step 4: Run Backend + +```bash +npm run dev +``` + +Backend will start on `http://localhost:3000` with auto-reload on file changes. + +#### Step 5: Configure Frontend + +Create `frontend/.env`: + +```bash +cd frontend +cat > .env << EOF +VITE_API_URL=http://localhost:3000 +EOF +``` + +#### Step 6: Install Frontend Dependencies + +```bash +cd frontend +npm install +``` + +#### Step 7: Run Frontend + +```bash +npm run dev +``` + +Frontend will start on `http://localhost:5173` with hot module replacement. + +### Verify Setup + +Test backend: +```bash +curl http://localhost:3000/health +# Should return: {"status":"ok","timestamp":"..."} +``` + +Test frontend: +```bash +open http://localhost:5173 +# Browser should open with login page +``` + +## Project Structure + +### Backend Structure + +``` +backend/ +├── src/ +│ ├── config/ # Configuration files +│ │ ├── database.ts # Database connection and pool +│ │ ├── environment.ts # Environment variables +│ │ └── oauth.ts # OAuth/Authentik configuration +│ ├── controllers/ # Request handlers +│ │ ├── auth.controller.ts +│ │ └── user.controller.ts +│ ├── database/ +│ │ └── migrations/ # SQL migration files +│ │ ├── 001_create_users_table.sql +│ │ └── 002_create_sessions_table.sql +│ ├── middleware/ # Express middleware +│ │ ├── auth.middleware.ts # Authentication +│ │ ├── error.middleware.ts # Error handling +│ │ └── rate-limit.middleware.ts # Rate limiting +│ ├── models/ # Data models +│ │ └── user.model.ts +│ ├── routes/ # API routes +│ │ ├── auth.routes.ts +│ │ └── user.routes.ts +│ ├── services/ # Business logic +│ │ ├── auth.service.ts +│ │ ├── oauth.service.ts +│ │ ├── token.service.ts +│ │ └── user.service.ts +│ ├── types/ # TypeScript types +│ │ ├── auth.types.ts +│ │ └── user.types.ts +│ ├── utils/ # Utility functions +│ │ └── logger.ts +│ ├── app.ts # Express app configuration +│ └── server.ts # Server entry point +├── logs/ # Application logs (gitignored) +├── Dockerfile +├── package.json +├── tsconfig.json +└── nodemon.json +``` + +### Frontend Structure + +``` +frontend/ +├── src/ +│ ├── components/ # Reusable React components +│ │ ├── common/ # Shared components +│ │ │ ├── Header.tsx +│ │ │ └── Loading.tsx +│ │ └── layout/ # Layout components +│ │ └── MainLayout.tsx +│ ├── contexts/ # React Context providers +│ │ └── AuthContext.tsx # Authentication state +│ ├── pages/ # Page components +│ │ ├── auth/ +│ │ │ ├── LoginPage.tsx +│ │ │ └── CallbackPage.tsx +│ │ ├── dashboard/ +│ │ │ └── DashboardPage.tsx +│ │ └── user/ +│ │ └── ProfilePage.tsx +│ ├── services/ # API service layer +│ │ ├── api.ts # Axios instance +│ │ ├── authService.ts # Auth API calls +│ │ └── userService.ts # User API calls +│ ├── theme/ # MUI theme configuration +│ │ └── theme.ts +│ ├── types/ # TypeScript types +│ │ └── auth.types.ts +│ ├── utils/ # Utility functions +│ │ └── storage.ts # Local storage helpers +│ ├── App.tsx # Main app component +│ ├── main.tsx # Entry point +│ └── vite-env.d.ts # Vite type declarations +├── public/ # Static assets +├── nginx.conf # Production Nginx config +├── Dockerfile +├── package.json +├── vite.config.ts +└── tsconfig.json +``` + +## Code Architecture + +### Backend Architecture + +#### Layered Architecture + +1. **Routes Layer** (`routes/`) + - Define API endpoints + - Input validation (Zod schemas) + - Route to controllers + +2. **Controllers Layer** (`controllers/`) + - Handle HTTP requests/responses + - Call services for business logic + - Format responses + +3. **Services Layer** (`services/`) + - Business logic implementation + - Database operations + - External API calls (OAuth) + +4. **Models Layer** (`models/`) + - Data structures + - Database queries + - Data validation + +5. **Middleware Layer** (`middleware/`) + - Authentication + - Error handling + - Rate limiting + - Logging + +#### Request Flow + +``` +Client Request + ↓ +Express Router (routes/) + ↓ +Middleware (auth, validation) + ↓ +Controller (controllers/) + ↓ +Service (services/) + ↓ +Model (models/) + ↓ +Database + ↓ +← Response flows back up +``` + +### Frontend Architecture + +#### Component-Based Architecture + +1. **Pages** - Route-level components +2. **Layouts** - Structural components (header, sidebar) +3. **Components** - Reusable UI elements +4. **Contexts** - Global state management +5. **Services** - API communication + +#### Data Flow + +``` +User Interaction + ↓ +Component Event Handler + ↓ +Service Layer (API call) + ↓ +Backend API + ↓ +Update Context/State + ↓ +Re-render Components +``` + +### Authentication Flow + +``` +1. User clicks "Login" + ↓ +2. Frontend redirects to Authentik + ↓ +3. User authenticates with Authentik + ↓ +4. Authentik redirects to /auth/callback with code + ↓ +5. Frontend sends code to backend /api/auth/callback + ↓ +6. Backend exchanges code for tokens with Authentik + ↓ +7. Backend creates user session in database + ↓ +8. Backend returns JWT access/refresh tokens + ↓ +9. Frontend stores tokens and updates AuthContext + ↓ +10. User is redirected to dashboard +``` + +## Development Workflow + +### 1. Create Feature Branch + +```bash +git checkout -b feature/your-feature-name +# or +git checkout -b fix/bug-description +``` + +### 2. Make Changes + +- Write code following style guidelines +- Add comments for complex logic +- Update types as needed + +### 3. Test Locally + +```bash +# Run backend tests +cd backend && npm test + +# Run frontend tests +cd frontend && npm test + +# Manual testing +# Test in browser at http://localhost:5173 +``` + +### 4. Commit Changes + +```bash +git add . +git commit -m "feat: add user profile editing feature" +``` + +Follow [Conventional Commits](https://www.conventionalcommits.org/): +- `feat:` - New feature +- `fix:` - Bug fix +- `docs:` - Documentation +- `refactor:` - Code refactoring +- `test:` - Adding tests +- `chore:` - Maintenance + +### 5. Push and Create PR + +```bash +git push origin feature/your-feature-name +``` + +Create pull request on your Git platform. + +## Adding New Features + +### Adding a New API Endpoint + +#### Step 1: Define Types + +Create or update `backend/src/types/your-feature.types.ts`: + +```typescript +export interface YourFeature { + id: number; + name: string; + createdAt: Date; +} + +export interface CreateYourFeatureDto { + name: string; +} +``` + +#### Step 2: Create Model + +Create `backend/src/models/your-feature.model.ts`: + +```typescript +import { pool } from '../config/database'; +import { YourFeature } from '../types/your-feature.types'; + +export const createYourFeature = async (name: string): Promise => { + const result = await pool.query( + 'INSERT INTO your_features (name) VALUES ($1) RETURNING *', + [name] + ); + return result.rows[0]; +}; +``` + +#### Step 3: Create Service + +Create `backend/src/services/your-feature.service.ts`: + +```typescript +import * as YourFeatureModel from '../models/your-feature.model'; +import { CreateYourFeatureDto } from '../types/your-feature.types'; + +class YourFeatureService { + async create(dto: CreateYourFeatureDto) { + // Business logic here + return await YourFeatureModel.createYourFeature(dto.name); + } +} + +export default new YourFeatureService(); +``` + +#### Step 4: Create Controller + +Create `backend/src/controllers/your-feature.controller.ts`: + +```typescript +import { Request, Response } from 'express'; +import yourFeatureService from '../services/your-feature.service'; + +class YourFeatureController { + async create(req: Request, res: Response) { + try { + const feature = await yourFeatureService.create(req.body); + res.status(201).json(feature); + } catch (error) { + res.status(500).json({ error: 'Failed to create feature' }); + } + } +} + +export default new YourFeatureController(); +``` + +#### Step 5: Create Routes + +Create `backend/src/routes/your-feature.routes.ts`: + +```typescript +import { Router } from 'express'; +import yourFeatureController from '../controllers/your-feature.controller'; +import { authenticate } from '../middleware/auth.middleware'; + +const router = Router(); + +router.post('/', authenticate, yourFeatureController.create); + +export default router; +``` + +#### Step 6: Register Routes + +Update `backend/src/app.ts`: + +```typescript +import yourFeatureRoutes from './routes/your-feature.routes'; + +app.use('/api/your-features', yourFeatureRoutes); +``` + +#### Step 7: Create Database Migration + +Create `backend/src/database/migrations/00X_create_your_features_table.sql`: + +```sql +CREATE TABLE IF NOT EXISTS your_features ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_your_features_name ON your_features(name); +``` + +### Adding a New Frontend Page + +#### Step 1: Create Page Component + +Create `frontend/src/pages/your-feature/YourFeaturePage.tsx`: + +```typescript +import React from 'react'; +import { Container, Typography } from '@mui/material'; + +const YourFeaturePage: React.FC = () => { + return ( + + Your Feature + + ); +}; + +export default YourFeaturePage; +``` + +#### Step 2: Create Service + +Create `frontend/src/services/yourFeatureService.ts`: + +```typescript +import api from './api'; + +export const createYourFeature = async (name: string) => { + const response = await api.post('/api/your-features', { name }); + return response.data; +}; +``` + +#### Step 3: Add Route + +Update `frontend/src/App.tsx`: + +```typescript +import YourFeaturePage from './pages/your-feature/YourFeaturePage'; + +// In your routes +} /> +``` + +## Testing Guidelines + +### Backend Testing + +Create `backend/src/__tests__/your-feature.test.ts`: + +```typescript +import yourFeatureService from '../services/your-feature.service'; + +describe('YourFeatureService', () => { + test('should create feature', async () => { + const feature = await yourFeatureService.create({ name: 'Test' }); + expect(feature.name).toBe('Test'); + }); +}); +``` + +Run tests: +```bash +cd backend +npm test +``` + +### Frontend Testing + +Create `frontend/src/__tests__/YourFeaturePage.test.tsx`: + +```typescript +import { render, screen } from '@testing-library/react'; +import YourFeaturePage from '../pages/your-feature/YourFeaturePage'; + +test('renders your feature page', () => { + render(); + expect(screen.getByText('Your Feature')).toBeInTheDocument(); +}); +``` + +Run tests: +```bash +cd frontend +npm test +``` + +### Manual Testing Checklist + +- [ ] Feature works in development mode +- [ ] Feature works in production build +- [ ] Authenticated users can access +- [ ] Unauthorized access is blocked +- [ ] Error states are handled +- [ ] Loading states are shown +- [ ] Responsive on mobile +- [ ] Works in Chrome, Firefox, Safari + +## Debugging + +### Backend Debugging + +#### VS Code Launch Configuration + +Create `.vscode/launch.json`: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug Backend", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "cwd": "${workspaceFolder}/backend", + "console": "integratedTerminal" + } + ] +} +``` + +#### Console Logging + +```typescript +import logger from '../utils/logger'; + +logger.debug('Debug message', { data }); +logger.info('Info message'); +logger.warn('Warning message'); +logger.error('Error message', { error }); +``` + +#### Database Queries + +Enable query logging in `backend/src/config/database.ts`: + +```typescript +const pool = new Pool({ + // ...config + log: (msg) => logger.debug(msg) +}); +``` + +### Frontend Debugging + +#### React DevTools + +Install [React Developer Tools](https://react.dev/learn/react-developer-tools) browser extension. + +#### Console Logging + +```typescript +console.log('Data:', data); +console.error('Error:', error); +``` + +#### Network Tab + +Use browser DevTools Network tab to inspect API calls. + +#### State Debugging + +```typescript +const [state, setState] = useState(initialState); + +useEffect(() => { + console.log('State changed:', state); +}, [state]); +``` + +## Common Issues + +### Database Connection Failed + +**Problem**: Backend can't connect to database + +**Solution**: +```bash +# Verify database is running +docker ps | grep postgres + +# Restart database +docker-compose -f docker-compose.dev.yml restart + +# Check logs +docker logs feuerwehr_db_dev +``` + +### Port Already in Use + +**Problem**: Port 3000 or 5173 already occupied + +**Solution**: +```bash +# Find process using port +lsof -i :3000 + +# Kill process +kill -9 + +# Or change port in package.json +``` + +### TypeScript Errors + +**Problem**: Type errors in VS Code + +**Solution**: +```bash +# Restart TypeScript server in VS Code +# Cmd+Shift+P > "TypeScript: Restart TS Server" + +# Rebuild +cd backend && npm run build +cd frontend && npm run build +``` + +### Authentik Redirect Fails + +**Problem**: OAuth callback returns error + +**Solution**: +1. Verify redirect URI exactly matches in Authentik +2. Check client ID and secret in `.env` +3. Ensure Authentik is accessible from browser +4. Check browser console for CORS errors + +### Hot Reload Not Working + +**Problem**: Changes don't reflect immediately + +**Solution**: +```bash +# Backend - check nodemon is running +# Frontend - restart Vite dev server +npm run dev +``` + +## Best Practices + +### Code Style + +- Use TypeScript strict mode +- Follow ESLint rules +- Use Prettier for formatting +- Write descriptive variable names +- Add JSDoc comments for public APIs + +### Security + +- Never commit secrets to Git +- Validate all inputs +- Use parameterized queries +- Sanitize user input +- Keep dependencies updated + +### Performance + +- Use database indexes +- Implement pagination for lists +- Cache expensive operations +- Optimize bundle size +- Use React.memo for expensive components + +### Git Workflow + +- Keep commits small and focused +- Write clear commit messages +- Rebase before merging +- Delete branches after merge +- Don't commit `node_modules/` or `.env` + +## Getting Help + +- **Documentation**: Check all `.md` files +- **Code Comments**: Read inline documentation +- **Team Chat**: Ask in development channel +- **GitHub Issues**: Search existing issues +- **Stack Overflow**: Search for similar problems + +## Next Steps + +After completing setup: + +1. Explore the codebase +2. Try adding a simple feature +3. Read [ARCHITECTURE.md](ARCHITECTURE.md) +4. Review [API_DOCUMENTATION.md](API_DOCUMENTATION.md) +5. Check [CONTRIBUTING.md](CONTRIBUTING.md) + +Happy coding! diff --git a/DOCKER_QUICK_REF.md b/DOCKER_QUICK_REF.md new file mode 100644 index 0000000..d0f3838 --- /dev/null +++ b/DOCKER_QUICK_REF.md @@ -0,0 +1,392 @@ +# Docker Quick Reference + +## Essential Commands + +### Initial Setup +```bash +# 1. Navigate to project +cd /Users/matthias/work/feuerwehr_dashboard + +# 2. Validate Docker setup +./docker-validate.sh + +# 3. Configure environment +cp .env.example .env +# Edit .env and set: +# POSTGRES_PASSWORD=your_secure_password +# JWT_SECRET=your_jwt_secret_min_32_chars + +# 4. Test Docker builds (optional) +./docker-test.sh +``` + +### Start Application +```bash +# Build and start all services +docker-compose up -d + +# View logs during startup +docker-compose logs -f + +# Check service health +docker-compose ps +``` + +### Access Services +- Frontend: http://localhost:80 +- Backend API: http://localhost:3000 +- PostgreSQL: localhost:5432 + +### Manage Services +```bash +# Stop services (keeps data) +docker-compose stop + +# Start stopped services +docker-compose start + +# Restart all services +docker-compose restart + +# Restart specific service +docker-compose restart backend + +# Stop and remove containers (keeps data) +docker-compose down + +# Stop, remove containers and volumes (DELETE DATA) +docker-compose down -v +``` + +### View Logs +```bash +# All services +docker-compose logs -f + +# Specific service +docker-compose logs -f backend +docker-compose logs -f frontend +docker-compose logs -f postgres + +# Last 100 lines +docker-compose logs --tail=100 backend + +# Since specific time +docker-compose logs --since 2024-01-01T00:00:00 +``` + +### Check Status +```bash +# Service status and health +docker-compose ps + +# Container resource usage +docker stats + +# Disk usage +docker system df +``` + +### Database Operations +```bash +# Access PostgreSQL CLI +docker-compose exec postgres psql -U prod_user -d feuerwehr_prod + +# Create backup +docker-compose exec postgres pg_dump -U prod_user feuerwehr_prod > backup.sql + +# Restore backup +cat backup.sql | docker-compose exec -T postgres psql -U prod_user feuerwehr_prod + +# View database logs +docker-compose logs -f postgres +``` + +### Rebuild Services +```bash +# Rebuild all images +docker-compose build + +# Rebuild without cache +docker-compose build --no-cache + +# Rebuild specific service +docker-compose build backend + +# Rebuild and restart +docker-compose up -d --build +``` + +### Execute Commands in Containers +```bash +# Backend shell +docker-compose exec backend sh + +# Frontend/Nginx shell +docker-compose exec frontend sh + +# Run command in backend +docker-compose exec backend node -v +docker-compose exec backend npm list + +# View backend environment +docker-compose exec backend env +``` + +### Troubleshooting +```bash +# View full configuration +docker-compose config + +# Validate docker-compose.yml +docker-compose config --quiet + +# Remove all stopped containers +docker container prune + +# Remove unused images +docker image prune + +# Remove unused volumes +docker volume prune + +# Remove everything (CAUTION!) +docker system prune -a +``` + +### Development Workflow +```bash +# 1. Make code changes in backend/frontend +# 2. Rebuild affected service +docker-compose build backend +# 3. Restart service +docker-compose up -d backend +# 4. Check logs +docker-compose logs -f backend +``` + +### Health Checks +```bash +# Check backend health +curl http://localhost:3000/health + +# Check frontend health +curl http://localhost:80/health + +# Check all services health +docker-compose ps +``` + +### Environment Variables +```bash +# View service environment +docker-compose exec backend env + +# Override environment variable +BACKEND_PORT=4000 docker-compose up -d + +# Use different env file +docker-compose --env-file .env.production up -d +``` + +## Build Individual Images + +### Backend +```bash +cd backend +docker build -t feuerwehr-backend:latest . +docker run -p 3000:3000 \ + -e DATABASE_URL=postgresql://user:pass@postgres:5432/db \ + -e JWT_SECRET=your_secret \ + feuerwehr-backend:latest +``` + +### Frontend +```bash +cd frontend +docker build \ + --build-arg VITE_API_URL=http://localhost:3000 \ + -t feuerwehr-frontend:latest . +docker run -p 80:80 feuerwehr-frontend:latest +``` + +## Production Deployment + +### Using Docker Compose +```bash +# 1. Set production environment variables +export POSTGRES_PASSWORD="secure_production_password" +export JWT_SECRET="secure_jwt_secret_min_32_chars" +export CORS_ORIGIN="https://yourdomain.com" +export VITE_API_URL="https://api.yourdomain.com" + +# 2. Build and start +docker-compose up -d + +# 3. Monitor +docker-compose logs -f +``` + +### Using Container Registry +```bash +# Tag images +docker tag feuerwehr-backend:latest registry.example.com/feuerwehr-backend:v1.0.0 +docker tag feuerwehr-frontend:latest registry.example.com/feuerwehr-frontend:v1.0.0 + +# Push to registry +docker push registry.example.com/feuerwehr-backend:v1.0.0 +docker push registry.example.com/feuerwehr-frontend:v1.0.0 + +# Pull on production server +docker pull registry.example.com/feuerwehr-backend:v1.0.0 +docker pull registry.example.com/feuerwehr-frontend:v1.0.0 + +# Run with docker-compose +docker-compose up -d +``` + +## Monitoring + +### Real-time Stats +```bash +# All containers +docker stats + +# Specific container +docker stats feuerwehr_backend_prod +``` + +### Inspect Containers +```bash +# Container details +docker inspect feuerwehr_backend_prod + +# Container IP address +docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' feuerwehr_backend_prod + +# Container logs location +docker inspect --format='{{.LogPath}}' feuerwehr_backend_prod +``` + +## Cleanup + +### Remove Test Images +```bash +docker rmi feuerwehr-backend-test:latest +docker rmi feuerwehr-frontend-test:latest +``` + +### Clean Build Cache +```bash +docker builder prune +``` + +### Remove Unused Resources +```bash +# Remove stopped containers +docker container prune -f + +# Remove unused images +docker image prune -f + +# Remove unused volumes +docker volume prune -f + +# Remove unused networks +docker network prune -f + +# Remove everything unused +docker system prune -af +``` + +## Security + +### Check for Vulnerabilities +```bash +# Scan backend image +docker scan feuerwehr-backend:latest + +# Scan frontend image +docker scan feuerwehr-frontend:latest +``` + +### Update Base Images +```bash +# Pull latest base images +docker pull node:20-alpine +docker pull nginx:alpine +docker pull postgres:16-alpine + +# Rebuild with new base images +docker-compose build --no-cache +docker-compose up -d +``` + +## Performance Tuning + +### Resource Limits +Add to docker-compose.yml: +```yaml +services: + backend: + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + reservations: + cpus: '0.5' + memory: 256M +``` + +### Check Resource Usage +```bash +docker stats --no-stream +``` + +## Common Issues + +### Port Already in Use +```bash +# Find process using port +lsof -i :3000 + +# Kill process +kill -9 + +# Or use different port +BACKEND_PORT=3001 docker-compose up -d +``` + +### Database Connection Failed +```bash +# Check postgres is healthy +docker-compose ps postgres + +# View postgres logs +docker-compose logs postgres + +# Restart postgres +docker-compose restart postgres +``` + +### Container Crashes +```bash +# View crash logs +docker-compose logs backend + +# Inspect container +docker inspect feuerwehr_backend_prod + +# Check health +docker inspect --format='{{.State.Health.Status}}' feuerwehr_backend_prod +``` + +## References + +- [Docker Setup Documentation](DOCKER_SETUP.md) +- [Summary Document](SUMMARY.md) +- [Backend Dockerfile](backend/Dockerfile) +- [Frontend Dockerfile](frontend/Dockerfile) +- [Nginx Configuration](frontend/nginx.conf) +- [Docker Compose File](docker-compose.yml) diff --git a/DOCKER_SETUP.md b/DOCKER_SETUP.md new file mode 100644 index 0000000..c6849a8 --- /dev/null +++ b/DOCKER_SETUP.md @@ -0,0 +1,501 @@ +# Docker Setup Documentation + +## Overview + +This document describes the production-ready Docker setup for the Feuerwehr Dashboard application, including multi-stage builds, security best practices, and deployment instructions. + +## Architecture + +The application consists of three Docker containers: + +1. **PostgreSQL Database** - postgres:16-alpine +2. **Backend API** - Node.js application (TypeScript compiled to JavaScript) +3. **Frontend** - React SPA served by Nginx + +All containers are connected via a Docker bridge network and orchestrated using Docker Compose. + +## Files Structure + +``` +feuerwehr_dashboard/ +├── docker-compose.yml # Production orchestration +├── docker-compose.dev.yml # Development orchestration +├── docker-test.sh # Docker build testing script +├── backend/ +│ ├── Dockerfile # Multi-stage backend build +│ └── .dockerignore # Backend Docker ignore rules +└── frontend/ + ├── Dockerfile # Multi-stage frontend build + ├── nginx.conf # Nginx configuration for SPA + └── .dockerignore # Frontend Docker ignore rules +``` + +## Backend Dockerfile + +**Location:** `/backend/Dockerfile` + +### Features + +- **Multi-stage build** - Separate build and production stages +- **Build stage:** + - Based on node:20-alpine + - Installs all dependencies (including devDependencies) + - Compiles TypeScript to JavaScript + - Prunes devDependencies after build +- **Production stage:** + - Based on node:20-alpine + - Installs wget for health checks + - Creates non-root user (nodejs:1001) + - Copies only production node_modules + - Copies compiled JavaScript + - Copies database migrations to dist/ + - Runs as non-root user + - Exposes port 3000 + - Includes health check + +### Build Command + +```bash +cd backend +docker build -t feuerwehr-backend:latest . +``` + +### Image Size + +Expected final image size: ~150-200 MB (alpine-based) + +### Security Features + +- Non-root user execution +- Minimal base image (Alpine Linux) +- Only production dependencies included +- No source code in final image + +## Frontend Dockerfile + +**Location:** `/frontend/Dockerfile` + +### Features + +- **Multi-stage build** - Build stage with Node.js + production stage with Nginx +- **Build stage:** + - Based on node:20-alpine + - Installs dependencies + - Runs Vite build + - Accepts build arguments for environment variables +- **Production stage:** + - Based on nginx:alpine + - Installs wget for health checks + - Copies custom nginx.conf + - Copies built static assets + - Configures non-root nginx user + - Exposes port 80 + - Includes health check + +### Build Command + +```bash +cd frontend +docker build \ + --build-arg VITE_API_URL=http://localhost:3000 \ + --build-arg VITE_APP_NAME="Feuerwehr Dashboard" \ + --build-arg VITE_APP_VERSION="1.0.0" \ + -t feuerwehr-frontend:latest . +``` + +### Build Arguments + +- `VITE_API_URL` - Backend API URL (default: http://localhost:3000) +- `VITE_APP_NAME` - Application name (default: "Feuerwehr Dashboard") +- `VITE_APP_VERSION` - Application version (default: "1.0.0") + +### Image Size + +Expected final image size: ~50-80 MB (alpine + static assets) + +### Security Features + +- Non-root nginx execution +- Minimal base image (Alpine Linux) +- Security headers configured +- No source code in final image + +## Nginx Configuration + +**Location:** `/frontend/nginx.conf` + +### Features + +1. **SPA Routing** + - All routes fall back to index.html + - Proper handling of client-side routing + +2. **Performance** + - Gzip compression enabled + - Static asset caching (1 year) + - No caching for index.html + +3. **Security Headers** + - X-Frame-Options: SAMEORIGIN + - X-Content-Type-Options: nosniff + - X-XSS-Protection: 1; mode=block + - Referrer-Policy: strict-origin-when-cross-origin + +4. **Health Check** + - Endpoint: /health + - Returns 200 with "healthy" text + +5. **Error Handling** + - 404 errors redirect to index.html + - Custom 50x error page + +## Docker Compose + +**Location:** `/docker-compose.yml` + +### Services + +#### PostgreSQL + +```yaml +postgres: + image: postgres:16-alpine + ports: 5432:5432 + volumes: postgres_data_prod + health_check: pg_isready + restart: unless-stopped +``` + +#### Backend + +```yaml +backend: + build: ./backend + ports: 3000:3000 + depends_on: postgres (healthy) + health_check: wget localhost:3000/health + restart: unless-stopped +``` + +#### Frontend + +```yaml +frontend: + build: ./frontend + build_args: VITE_API_URL + ports: 80:80 + depends_on: backend (healthy) + health_check: wget localhost:80/health + restart: unless-stopped +``` + +### Environment Variables + +Create a `.env` file based on `.env.example`: + +**Required:** +- `POSTGRES_PASSWORD` - Database password +- `JWT_SECRET` - JWT signing secret + +**Optional:** +- `POSTGRES_DB` - Database name (default: feuerwehr_prod) +- `POSTGRES_USER` - Database user (default: prod_user) +- `POSTGRES_PORT` - Database port (default: 5432) +- `BACKEND_PORT` - Backend port (default: 3000) +- `FRONTEND_PORT` - Frontend port (default: 80) +- `CORS_ORIGIN` - CORS origin (default: http://localhost:80) +- `VITE_API_URL` - Frontend API URL (default: http://localhost:3000) + +### Networks + +- `feuerwehr_network` - Bridge network connecting all services + +### Volumes + +- `postgres_data_prod` - Persistent PostgreSQL data + +## Usage + +### 1. Test Docker Builds + +Run the test script to verify Docker builds work: + +```bash +./docker-test.sh +``` + +This script will: +- Check Docker availability +- Build backend image +- Build frontend image +- Report success/failure +- Optionally cleanup test images + +### 2. Configure Environment + +Copy and configure environment file: + +```bash +cp .env.example .env +# Edit .env and set required variables +``` + +### 3. Build and Start Services + +```bash +# Build and start all services +docker-compose up -d + +# View logs +docker-compose logs -f + +# Check service status +docker-compose ps +``` + +### 4. Access Application + +- **Frontend:** http://localhost:80 +- **Backend API:** http://localhost:3000 +- **Database:** localhost:5432 + +### 5. Stop Services + +```bash +# Stop all services +docker-compose down + +# Stop and remove volumes +docker-compose down -v +``` + +## Health Checks + +All services include health checks: + +### PostgreSQL +- **Command:** `pg_isready -U $USER -d $DB` +- **Interval:** 10s +- **Retries:** 5 + +### Backend +- **Command:** `wget localhost:3000/health` +- **Interval:** 30s +- **Retries:** 3 +- **Start Period:** 40s + +### Frontend +- **Command:** `wget localhost:80/health` +- **Interval:** 30s +- **Retries:** 3 +- **Start Period:** 30s + +## Troubleshooting + +### Backend Build Fails + +1. Check TypeScript compilation: +```bash +cd backend +npm install +npm run build +``` + +2. Verify all source files exist +3. Check tsconfig.json configuration + +### Frontend Build Fails + +1. Check Vite build: +```bash +cd frontend +npm install +npm run build +``` + +2. Verify all dependencies are installed +3. Check build arguments are correct + +### Container Won't Start + +1. Check logs: +```bash +docker-compose logs [service-name] +``` + +2. Verify environment variables in .env +3. Check health check status: +```bash +docker-compose ps +``` + +### Database Connection Issues + +1. Verify DATABASE_URL format: +``` +postgresql://user:password@postgres:5432/database +``` + +2. Check postgres service is healthy: +```bash +docker-compose ps postgres +``` + +3. Verify network connectivity: +```bash +docker-compose exec backend ping postgres +``` + +## Security Considerations + +### Production Checklist + +- [ ] Set strong POSTGRES_PASSWORD +- [ ] Set strong JWT_SECRET (min 32 characters) +- [ ] Use HTTPS in production +- [ ] Configure proper CORS_ORIGIN +- [ ] Enable firewall rules +- [ ] Regular security updates +- [ ] Monitor container logs +- [ ] Backup database regularly + +### Image Security + +- Alpine Linux base (minimal attack surface) +- Non-root user execution +- No unnecessary packages +- Security headers enabled +- Health checks configured + +## Maintenance + +### Update Dependencies + +```bash +# Rebuild images with latest dependencies +docker-compose build --no-cache +docker-compose up -d +``` + +### Backup Database + +```bash +# Create backup +docker-compose exec postgres pg_dump -U $USER $DB > backup.sql + +# Restore backup +docker-compose exec -T postgres psql -U $USER $DB < backup.sql +``` + +### View Logs + +```bash +# All services +docker-compose logs -f + +# Specific service +docker-compose logs -f backend + +# Last 100 lines +docker-compose logs --tail=100 backend +``` + +### Restart Services + +```bash +# Restart all +docker-compose restart + +# Restart specific service +docker-compose restart backend +``` + +## Performance Optimization + +### Build Cache + +Docker uses layer caching. To optimize: + +1. Copy package files first +2. Install dependencies +3. Copy source code last + +This ensures dependency installation is cached. + +### Image Size + +Current image sizes: +- Backend: ~150-200 MB +- Frontend: ~50-80 MB +- Total: ~200-280 MB + +### Resource Limits + +Add resource limits in docker-compose.yml: + +```yaml +services: + backend: + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M +``` + +## Monitoring + +### Container Stats + +```bash +docker stats +``` + +### Health Status + +```bash +docker-compose ps +``` + +### Resource Usage + +```bash +docker system df +``` + +## Production Deployment + +### Using Docker Compose + +```bash +# On production server +git clone +cd feuerwehr_dashboard +cp .env.example .env +# Configure .env with production values +docker-compose up -d +``` + +### Using Container Registry + +```bash +# Build and tag images +docker build -t registry.example.com/feuerwehr-backend:v1.0.0 backend/ +docker build -t registry.example.com/feuerwehr-frontend:v1.0.0 frontend/ + +# Push to registry +docker push registry.example.com/feuerwehr-backend:v1.0.0 +docker push registry.example.com/feuerwehr-frontend:v1.0.0 + +# Pull and run on production +docker pull registry.example.com/feuerwehr-backend:v1.0.0 +docker pull registry.example.com/feuerwehr-frontend:v1.0.0 +docker-compose up -d +``` + +## Additional Resources + +- [Docker Documentation](https://docs.docker.com/) +- [Docker Compose Documentation](https://docs.docker.com/compose/) +- [Nginx Documentation](https://nginx.org/en/docs/) +- [Node.js Best Practices](https://github.com/goldbergyoni/nodebestpractices) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a083816 --- /dev/null +++ b/Makefile @@ -0,0 +1,98 @@ +.PHONY: help dev prod stop logs logs-dev logs-prod rebuild rebuild-dev clean install test + +# Default target +help: + @echo "Feuerwehr Dashboard - Available Commands" + @echo "" + @echo "Development:" + @echo " make dev - Start local development database" + @echo " make logs-dev - Show development logs" + @echo " make rebuild-dev - Rebuild development services" + @echo "" + @echo "Production:" + @echo " make prod - Deploy production environment" + @echo " make logs-prod - Show production logs" + @echo " make rebuild - Rebuild production services" + @echo "" + @echo "General:" + @echo " make stop - Stop all services" + @echo " make clean - Remove all containers and volumes" + @echo " make install - Install dependencies for backend and frontend" + @echo " make test - Run tests" + @echo "" + +# Development +dev: + @echo "Starting local development database..." + docker-compose -f docker-compose.dev.yml up -d + @echo "" + @echo "Database is ready at localhost:5432" + @echo "Database: feuerwehr_dev" + @echo "User: dev_user" + @echo "Password: dev_password" + +logs-dev: + docker-compose -f docker-compose.dev.yml logs -f + +rebuild-dev: + docker-compose -f docker-compose.dev.yml up -d --build --force-recreate + +# Production +prod: + @if [ ! -f .env ]; then \ + echo "Error: .env file not found!"; \ + echo "Please copy .env.example to .env and configure it:"; \ + echo " cp .env.example .env"; \ + exit 1; \ + fi + @echo "Starting production deployment..." + docker-compose -f docker-compose.yml up -d --build + @echo "" + @echo "Production services are running!" + +logs-prod: + docker-compose -f docker-compose.yml logs -f + +logs: + @make logs-prod + +rebuild: + docker-compose -f docker-compose.yml up -d --build --force-recreate + +# General commands +stop: + @echo "Stopping all services..." + docker-compose -f docker-compose.yml down 2>/dev/null || true + docker-compose -f docker-compose.dev.yml down 2>/dev/null || true + @echo "All services stopped" + +clean: + @echo "Warning: This will remove all containers, volumes, and images!" + @read -p "Are you sure? [y/N] " -n 1 -r; \ + echo; \ + if [ "$$REPLY" = "y" ] || [ "$$REPLY" = "Y" ]; then \ + docker-compose -f docker-compose.yml down -v 2>/dev/null || true; \ + docker-compose -f docker-compose.dev.yml down -v 2>/dev/null || true; \ + docker images | grep feuerwehr | awk '{print $$3}' | xargs docker rmi -f 2>/dev/null || true; \ + echo "Cleanup complete!"; \ + else \ + echo "Cleanup cancelled"; \ + fi + +# Install dependencies +install: + @echo "Installing backend dependencies..." + cd backend && npm install + @echo "" + @echo "Installing frontend dependencies..." + cd frontend && npm install + @echo "" + @echo "Dependencies installed!" + +# Run tests +test: + @echo "Running backend tests..." + cd backend && npm test + @echo "" + @echo "Running frontend tests..." + cd frontend && npm test diff --git a/README.md b/README.md new file mode 100644 index 0000000..7387e46 --- /dev/null +++ b/README.md @@ -0,0 +1,410 @@ +# Feuerwehr Dashboard + +A modern, secure web application for managing fire department operations with integrated Authentik SSO authentication. + +## Overview + +The Feuerwehr Dashboard is a full-stack web application designed specifically for fire departments (Feuerwehr) to manage operations, personnel, and administrative tasks. Built with modern technologies and security best practices, it provides a robust platform for fire service management. + +## Features + +### Authentication & Security +- OAuth 2.0 / OpenID Connect integration with Authentik +- Secure JWT-based session management +- Role-based access control (RBAC) +- Automatic token refresh +- Rate limiting and security headers (Helmet.js) +- CORS protection + +### Dashboard & User Management +- Modern, responsive Material-UI interface +- Real-time user profile management +- Secure user session handling +- Multi-device support +- Dark mode ready + +### Service Management +- Extensible service architecture +- Database-backed operations +- RESTful API design +- Health monitoring endpoints + +## Tech Stack + +### Frontend +- **React 18** - Modern UI library +- **TypeScript** - Type-safe development +- **Material-UI (MUI)** - Component library +- **React Router** - Client-side routing +- **Axios** - HTTP client +- **Vite** - Build tool and dev server +- **Nginx** - Production web server + +### Backend +- **Node.js 18+** - Runtime environment +- **Express 5** - Web framework +- **TypeScript** - Type-safe development +- **PostgreSQL 16** - Database +- **jsonwebtoken** - JWT handling +- **Helmet** - Security middleware +- **Winston** - Logging +- **Zod** - Schema validation + +### Infrastructure +- **Docker & Docker Compose** - Containerization +- **PostgreSQL 16 Alpine** - Production database +- **Nginx Alpine** - Frontend serving +- **Health checks** - Service monitoring + +## Prerequisites + +- **Docker** 20.10+ and **Docker Compose** 2.0+ +- **Authentik** instance (self-hosted or cloud) +- **Node.js 18+** (for local development) +- **Git** + +## Quick Start + +Get the dashboard running in 3 simple steps: + +### 1. Clone and Configure + +```bash +git clone +cd feuerwehr_dashboard +cp .env.example .env +``` + +Edit `.env` with your configuration (see [Environment Variables](#environment-variables) section). + +### 2. Configure Authentik + +Follow the [Authentik Setup Guide](AUTHENTIK_SETUP.md) to create and configure your OAuth application. + +### 3. Deploy + +```bash +make prod +# or +./deploy.sh production +``` + +The dashboard will be available at `http://localhost` (or your configured domain). + +## Project Structure + +``` +feuerwehr_dashboard/ +├── backend/ # Node.js/Express backend +│ ├── src/ +│ │ ├── config/ # Configuration files +│ │ ├── controllers/ # Request handlers +│ │ ├── database/ # Database setup and migrations +│ │ ├── middleware/ # Express middleware +│ │ ├── models/ # Data models +│ │ ├── routes/ # API routes +│ │ ├── services/ # Business logic +│ │ ├── types/ # TypeScript types +│ │ ├── utils/ # Utility functions +│ │ ├── app.ts # Express app setup +│ │ └── server.ts # Server entry point +│ ├── Dockerfile # Backend container +│ └── package.json +├── frontend/ # React/TypeScript frontend +│ ├── src/ +│ │ ├── components/ # Reusable components +│ │ ├── contexts/ # React contexts +│ │ ├── pages/ # Page components +│ │ ├── services/ # API services +│ │ ├── theme/ # MUI theme +│ │ ├── types/ # TypeScript types +│ │ ├── utils/ # Utility functions +│ │ ├── App.tsx # Main app component +│ │ └── main.tsx # Entry point +│ ├── Dockerfile # Frontend container +│ ├── nginx.conf # Nginx configuration +│ └── package.json +├── docker-compose.yml # Production deployment +├── docker-compose.dev.yml # Development database +├── .env.example # Environment template +├── deploy.sh # Deployment script +├── Makefile # Make commands +├── README.md # This file +├── DEPLOYMENT.md # Deployment guide +├── DEVELOPMENT.md # Developer guide +├── AUTHENTIK_SETUP.md # Authentik configuration +├── API_DOCUMENTATION.md # API reference +├── ARCHITECTURE.md # System architecture +├── SECURITY.md # Security guidelines +├── CONTRIBUTING.md # Contribution guide +└── CHANGELOG.md # Version history +``` + +## Development Setup + +For local development without Docker, follow these steps: + +### 1. Start Development Database + +```bash +make dev +# or +./deploy.sh local +``` + +This starts a PostgreSQL container with development credentials. + +### 2. Install Dependencies + +```bash +make install +# or manually: +cd backend && npm install +cd ../frontend && npm install +``` + +### 3. Configure Environment + +Backend `.env` (in `backend/.env`): +```bash +DATABASE_URL=postgresql://dev_user:dev_password@localhost:5432/feuerwehr_dev +JWT_SECRET=your_development_secret +NODE_ENV=development +PORT=3000 +``` + +Frontend `.env` (in `frontend/.env`): +```bash +VITE_API_URL=http://localhost:3000 +``` + +### 4. Run Services + +Terminal 1 - Backend: +```bash +cd backend +npm run dev +``` + +Terminal 2 - Frontend: +```bash +cd frontend +npm run dev +``` + +Access the app at `http://localhost:5173` (Vite dev server). + +For more details, see [DEVELOPMENT.md](DEVELOPMENT.md). + +## Production Deployment + +For complete production deployment instructions, see [DEPLOYMENT.md](DEPLOYMENT.md). + +### Quick Production Setup + +1. **Configure environment variables** in `.env`: + - Set strong `POSTGRES_PASSWORD` + - Generate secure `JWT_SECRET` (use `openssl rand -base64 32`) + - Configure `CORS_ORIGIN` to match your frontend URL + - Set Authentik credentials + +2. **Deploy all services**: + ```bash + make prod + ``` + +3. **Verify deployment**: + ```bash + docker-compose ps + make logs-prod + ``` + +## Environment Variables + +### Required Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `POSTGRES_PASSWORD` | Database password (production) | `SecureP@ssw0rd!` | +| `JWT_SECRET` | JWT signing secret | `openssl rand -base64 32` | +| `AUTHENTIK_CLIENT_ID` | OAuth client ID from Authentik | `abc123...` | +| `AUTHENTIK_CLIENT_SECRET` | OAuth client secret | `xyz789...` | +| `AUTHENTIK_ISSUER` | Authentik issuer URL | `https://auth.example.com/application/o/feuerwehr/` | + +### Optional Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `POSTGRES_DB` | Database name | `feuerwehr_prod` | +| `POSTGRES_USER` | Database user | `prod_user` | +| `POSTGRES_PORT` | Database port | `5432` | +| `BACKEND_PORT` | Backend API port | `3000` | +| `FRONTEND_PORT` | Frontend port | `80` | +| `NODE_ENV` | Node environment | `production` | +| `CORS_ORIGIN` | Allowed frontend origin | `http://localhost:80` | +| `VITE_API_URL` | Frontend API URL | `http://localhost:3000` | + +For complete documentation, see [.env.example](.env.example). + +## Available Commands + +### Using Make (Recommended) + +```bash +# Development +make dev # Start local development database +make logs-dev # Show development logs +make rebuild-dev # Rebuild development services + +# Production +make prod # Deploy production environment +make logs-prod # Show production logs +make rebuild # Rebuild production services + +# General +make stop # Stop all services +make clean # Remove all containers, volumes, and images +make install # Install all dependencies +make test # Run tests +make help # Show all available commands +``` + +### Using Deploy Script + +```bash +./deploy.sh production # Deploy production +./deploy.sh local # Start local dev database +./deploy.sh stop # Stop all services +./deploy.sh logs # Show production logs +./deploy.sh rebuild # Rebuild services +./deploy.sh clean # Clean up everything +``` + +## Troubleshooting + +### Database Connection Failed + +**Symptoms**: Backend logs show database connection errors + +**Solutions**: +```bash +# Check if database is running +docker-compose ps + +# View database logs +make logs-dev # or logs-prod + +# Restart database +docker-compose restart postgres + +# Verify credentials in .env match docker-compose.yml +``` + +### Authentication Not Working + +**Symptoms**: Login redirects fail or return errors + +**Solutions**: +1. Verify Authentik configuration (see [AUTHENTIK_SETUP.md](AUTHENTIK_SETUP.md)) +2. Check redirect URIs match exactly +3. Verify `AUTHENTIK_CLIENT_ID` and `AUTHENTIK_CLIENT_SECRET` in `.env` +4. Check browser console for CORS errors +5. Ensure `CORS_ORIGIN` matches your frontend URL + +### Port Already in Use + +**Symptoms**: Docker fails to start with "port already allocated" + +**Solutions**: +```bash +# Find what's using the port +lsof -i :3000 # or :80, :5432 + +# Change ports in .env +BACKEND_PORT=3001 +FRONTEND_PORT=8080 +POSTGRES_PORT=5433 +``` + +### Frontend Can't Reach Backend + +**Symptoms**: API calls fail with network errors + +**Solutions**: +1. Verify `VITE_API_URL` in `.env` matches backend URL +2. Check `CORS_ORIGIN` in backend allows frontend URL +3. Ensure backend is running: `curl http://localhost:3000/health` +4. Check network connectivity between containers + +### Complete Reset + +If all else fails: +```bash +make clean # Removes everything +make prod # Fresh deployment +``` + +For more issues, see [DEVELOPMENT.md](DEVELOPMENT.md#common-issues). + +## API Documentation + +See [API_DOCUMENTATION.md](API_DOCUMENTATION.md) for complete API reference. + +### Quick API Overview + +- `POST /api/auth/callback` - Handle OAuth callback +- `POST /api/auth/refresh` - Refresh access token +- `POST /api/auth/logout` - Logout user +- `GET /api/user/me` - Get current user +- `GET /health` - Health check endpoint + +## Security + +This application implements security best practices: + +- OAuth 2.0 / OpenID Connect authentication +- JWT-based stateless sessions +- HTTP security headers (Helmet.js) +- CORS protection +- Rate limiting +- SQL injection protection (parameterized queries) +- XSS protection +- Secure password handling (delegated to Authentik) + +For security guidelines and reporting vulnerabilities, see [SECURITY.md](SECURITY.md). + +## Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for: + +- Code style guidelines +- Development workflow +- Pull request process +- Issue reporting + +## Architecture + +For system architecture, component diagrams, and data flow, see [ARCHITECTURE.md](ARCHITECTURE.md). + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for version history and release notes. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Support + +For questions or issues: + +1. Check [Troubleshooting](#troubleshooting) section +2. Review relevant documentation files +3. Search existing issues +4. Create a new issue with detailed information + +## Acknowledgments + +- Built with modern open-source technologies +- Designed for fire department operations management +- Inspired by the need for secure, user-friendly emergency services software diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..b96cef0 --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,448 @@ +# Docker Setup Summary + +## Completed Tasks + +All Docker setup tasks have been successfully completed for the Feuerwehr Dashboard project. + +## Files Created + +### 1. Backend Dockerfile +**Location:** `/Users/matthias/work/feuerwehr_dashboard/backend/Dockerfile` +- Multi-stage build (builder + production) +- Base image: node:20-alpine +- Build stage: Installs deps, compiles TypeScript +- Production stage: Only production deps, non-root user +- Copies migrations folder to dist/ +- Health check: wget localhost:3000/health +- Exposes port 3000 +- CMD: node dist/server.js +- **Lines:** 69 + +### 2. Backend .dockerignore +**Location:** `/Users/matthias/work/feuerwehr_dashboard/backend/.dockerignore` +- Excludes node_modules, dist, .env, logs +- Excludes IDE files, git files, documentation +- Optimizes build context size + +### 3. Frontend Dockerfile +**Location:** `/Users/matthias/work/feuerwehr_dashboard/frontend/Dockerfile` +- Multi-stage build (builder + nginx) +- Builder: node:20-alpine, runs Vite build +- Production: nginx:alpine, serves static assets +- Build arguments: VITE_API_URL, VITE_APP_NAME, VITE_APP_VERSION +- Non-root nginx user +- Health check: wget localhost:80/health +- Exposes port 80 +- CMD: nginx -g "daemon off;" +- **Lines:** 68 + +### 4. Frontend .dockerignore +**Location:** `/Users/matthias/work/feuerwehr_dashboard/frontend/.dockerignore` +- Excludes node_modules, dist, .env +- Excludes IDE files, git files, documentation +- Optimizes build context size + +### 5. Nginx Configuration +**Location:** `/Users/matthias/work/feuerwehr_dashboard/frontend/nginx.conf` +- SPA routing: try_files with fallback to index.html +- Gzip compression enabled +- Static assets cached for 1 year +- index.html not cached +- Security headers: + - X-Frame-Options: SAMEORIGIN + - X-Content-Type-Options: nosniff + - X-XSS-Protection: 1; mode=block + - Referrer-Policy: strict-origin-when-cross-origin +- Health check endpoint: /health +- Custom error pages +- **Lines:** 99 + +### 6. Updated docker-compose.yml +**Location:** `/Users/matthias/work/feuerwehr_dashboard/docker-compose.yml` +- Fixed backend PORT environment variable (hardcoded to 3000) +- Fixed backend port mapping (maps to container port 3000) +- Fixed health check URL (uses container port 3000) +- All services have proper health checks +- Proper depends_on with health conditions +- Volume for postgres data persistence +- Bridge network for service communication +- Restart policies: unless-stopped + +### 7. Docker Test Script +**Location:** `/Users/matthias/work/feuerwehr_dashboard/docker-test.sh` +- Checks Docker availability +- Tests backend image build +- Tests frontend image build +- Reports image sizes +- Option to cleanup test images +- Provides next steps +- **Lines:** 250 +- **Executable:** Yes + +### 8. Docker Validation Script +**Location:** `/Users/matthias/work/feuerwehr_dashboard/docker-validate.sh` +- Validates all Docker files exist +- Checks file permissions +- Reports missing files +- **Executable:** Yes + +### 9. Docker Setup Documentation +**Location:** `/Users/matthias/work/feuerwehr_dashboard/DOCKER_SETUP.md` +- Comprehensive Docker setup guide +- Architecture overview +- Build instructions +- Security considerations +- Troubleshooting guide +- Production deployment guide + +## Docker Build Status + +**Note:** Docker is not available on this system, so builds could not be tested automatically. + +### To Test Builds + +When Docker is available, run: + +```bash +cd /Users/matthias/work/feuerwehr_dashboard +./docker-test.sh +``` + +This will: +1. Verify Docker is running +2. Build backend image (tag: feuerwehr-backend-test:latest) +3. Build frontend image (tag: feuerwehr-frontend-test:latest) +4. Report image sizes +5. Optionally cleanup test images + +### Manual Build Testing + +#### Backend +```bash +cd /Users/matthias/work/feuerwehr_dashboard/backend +docker build -t feuerwehr-backend:latest . +``` + +Expected size: ~150-200 MB + +#### Frontend +```bash +cd /Users/matthias/work/feuerwehr_dashboard/frontend +docker build \ + --build-arg VITE_API_URL=http://localhost:3000 \ + --build-arg VITE_APP_NAME="Feuerwehr Dashboard" \ + --build-arg VITE_APP_VERSION="1.0.0" \ + -t feuerwehr-frontend:latest . +``` + +Expected size: ~50-80 MB + +## Docker Compose Services + +### PostgreSQL +- Image: postgres:16-alpine +- Port: 5432 +- Volume: postgres_data_prod (persistent) +- Health check: pg_isready +- Restart: unless-stopped + +### Backend +- Build: ./backend +- Port: 3000 +- Depends on: postgres (healthy) +- Health check: wget localhost:3000/health +- Restart: unless-stopped + +### Frontend +- Build: ./frontend +- Port: 80 +- Depends on: backend (healthy) +- Health check: wget localhost:80/health +- Restart: unless-stopped + +## Environment Variables + +Create `.env` file based on `.env.example`: + +### Required Variables +```bash +POSTGRES_PASSWORD=your_secure_password +JWT_SECRET=your_jwt_secret_min_32_chars +``` + +### Optional Variables (with defaults) +```bash +POSTGRES_DB=feuerwehr_prod +POSTGRES_USER=prod_user +POSTGRES_PORT=5432 +BACKEND_PORT=3000 +FRONTEND_PORT=80 +CORS_ORIGIN=http://localhost:80 +VITE_API_URL=http://localhost:3000 +``` + +## Quick Start Guide + +### 1. Validate Setup +```bash +cd /Users/matthias/work/feuerwehr_dashboard +./docker-validate.sh +``` + +### 2. Configure Environment +```bash +cp .env.example .env +# Edit .env and set POSTGRES_PASSWORD and JWT_SECRET +``` + +### 3. Test Builds (Optional) +```bash +./docker-test.sh +``` + +### 4. Start Services +```bash +docker-compose up -d +``` + +### 5. Check Status +```bash +docker-compose ps +docker-compose logs -f +``` + +### 6. Access Application +- Frontend: http://localhost:80 +- Backend: http://localhost:3000 +- Database: localhost:5432 + +## Security Features + +### Backend +- Non-root user execution (nodejs:1001) +- Alpine Linux base (minimal attack surface) +- Only production dependencies +- No source code in final image +- Health checks enabled +- wget installed for monitoring + +### Frontend +- Non-root nginx execution +- Alpine Linux base +- Security headers configured +- Static assets only in final image +- Gzip compression +- Proper cache headers +- Health checks enabled + +## Image Optimization + +### Multi-Stage Builds +- Separate build and production stages +- Smaller final images +- No build tools in production + +### Layer Caching +- Package files copied first +- Dependencies installed before source +- Optimized build times + +### .dockerignore +- Excludes unnecessary files +- Reduces build context +- Faster builds + +## Health Checks + +All services include Docker health checks: + +### PostgreSQL +- Interval: 10s +- Retries: 5 +- Start period: 10s + +### Backend +- Endpoint: /health +- Interval: 30s +- Retries: 3 +- Start period: 40s + +### Frontend +- Endpoint: /health +- Interval: 30s +- Retries: 3 +- Start period: 30s + +## Production Considerations + +### Before Deployment +- [ ] Set strong POSTGRES_PASSWORD (min 16 chars) +- [ ] Set strong JWT_SECRET (min 32 chars) +- [ ] Configure CORS_ORIGIN to production domain +- [ ] Set VITE_API_URL to production API URL +- [ ] Enable HTTPS/SSL +- [ ] Configure firewall rules +- [ ] Set up monitoring +- [ ] Configure backup strategy + +### Resource Limits +Consider adding resource limits in docker-compose.yml: + +```yaml +deploy: + resources: + limits: + cpus: '1.0' + memory: 512M +``` + +### Monitoring +```bash +# View container stats +docker stats + +# View logs +docker-compose logs -f + +# Check health +docker-compose ps +``` + +## Troubleshooting + +### Build Fails + +1. **Check Docker is running:** + ```bash + docker info + ``` + +2. **View build output:** + ```bash + docker-compose build --no-cache + ``` + +3. **Check individual service:** + ```bash + cd backend # or frontend + docker build -t test . + ``` + +### Container Won't Start + +1. **Check logs:** + ```bash + docker-compose logs [service] + ``` + +2. **Check health:** + ```bash + docker-compose ps + ``` + +3. **Verify environment:** + ```bash + docker-compose config + ``` + +### Database Connection Issues + +1. **Verify postgres is healthy:** + ```bash + docker-compose ps postgres + ``` + +2. **Check DATABASE_URL format:** + ``` + postgresql://user:password@postgres:5432/database + ``` + +3. **Test connection:** + ```bash + docker-compose exec backend ping postgres + ``` + +## Next Steps + +1. **Install Docker** (if not already installed) + - macOS: Docker Desktop for Mac + - Linux: Docker Engine + - Windows: Docker Desktop for Windows + +2. **Test Docker Setup** + ```bash + ./docker-test.sh + ``` + +3. **Configure Environment** + - Copy .env.example to .env + - Set required variables + +4. **Deploy Application** + ```bash + docker-compose up -d + ``` + +5. **Monitor Services** + ```bash + docker-compose logs -f + docker-compose ps + ``` + +## Additional Resources + +- [Docker Setup Documentation](DOCKER_SETUP.md) - Comprehensive guide +- [Backend Dockerfile](backend/Dockerfile) - Backend image definition +- [Frontend Dockerfile](frontend/Dockerfile) - Frontend image definition +- [Nginx Config](frontend/nginx.conf) - Web server configuration +- [Docker Compose](docker-compose.yml) - Service orchestration + +## File Locations + +All files are in the project root: `/Users/matthias/work/feuerwehr_dashboard/` + +``` +/Users/matthias/work/feuerwehr_dashboard/ +├── backend/ +│ ├── Dockerfile # Backend image (69 lines) +│ └── .dockerignore # Backend ignore rules +├── frontend/ +│ ├── Dockerfile # Frontend image (68 lines) +│ ├── nginx.conf # Nginx config (99 lines) +│ └── .dockerignore # Frontend ignore rules +├── docker-compose.yml # Service orchestration (updated) +├── docker-test.sh # Build test script (250 lines) +├── docker-validate.sh # Validation script +├── DOCKER_SETUP.md # Comprehensive documentation +└── SUMMARY.md # This file +``` + +## Validation Results + +All Docker files have been validated: + +``` +✓ backend/Dockerfile exists +✓ backend/.dockerignore exists +✓ frontend/Dockerfile exists +✓ frontend/.dockerignore exists +✓ frontend/nginx.conf exists +✓ docker-compose.yml exists +✓ docker-test.sh exists and is executable +``` + +## Summary + +The production-ready Docker setup is complete with: + +- **Multi-stage builds** for optimized image sizes +- **Security best practices** (non-root users, Alpine base) +- **Health checks** for all services +- **Layer caching** optimization +- **SPA routing** with Nginx +- **Gzip compression** and cache headers +- **Security headers** configured +- **Test scripts** for validation +- **Comprehensive documentation** + +The application is ready for Docker deployment once Docker is installed and environment variables are configured. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..d99c2c0 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,69 @@ +# Dependencies +node_modules +npm-debug.log +yarn-error.log +package-lock.json +yarn.lock + +# Build output +dist +build +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.*.local +*.env + +# IDE and editor files +.vscode +.idea +*.swp +*.swo +*~ +.DS_Store + +# Logs +logs +*.log +npm-debug.log* +pids +*.pid +*.seed +*.pid.lock + +# Testing +coverage +.nyc_output +test-results + +# Temporary files +tmp +temp +*.tmp + +# Git +.git +.gitignore +.gitattributes + +# Documentation +README.md +CHANGELOG.md +docs + +# CI/CD +.github +.gitlab-ci.yml +.travis.yml +Jenkinsfile + +# Docker +Dockerfile +.dockerignore +docker-compose*.yml + +# Misc +.cache +.parcel-cache diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..eff479c --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +dist/ +logs/ +*.log +.env +.env.development +.env.production +.DS_Store +coverage/ +*.swp +*.swo +*~ +.vscode/ +.idea/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a445d48 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,69 @@ +# =========================== +# Build Stage +# =========================== +FROM node:20-alpine AS builder + +# Set working directory +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache python3 make g++ + +# Copy package files for dependency installation +COPY package*.json ./ + +# Install all dependencies (including devDependencies for building) +RUN npm ci + +# Copy TypeScript configuration and source code +COPY tsconfig.json ./ +COPY src ./src + +# Build TypeScript to JavaScript +RUN npm run build + +# Prune dev dependencies +RUN npm prune --production + +# =========================== +# Production Stage +# =========================== +FROM node:20-alpine AS production + +# Install wget for health checks +RUN apk add --no-cache wget + +# Create non-root user for security +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Copy production node_modules from builder +COPY --from=builder /app/node_modules ./node_modules + +# Copy compiled JavaScript from builder +COPY --from=builder /app/dist ./dist + +# Copy database migrations (needed for runtime) +COPY --from=builder /app/src/database/migrations ./dist/database/migrations + +# Change ownership to non-root user +RUN chown -R nodejs:nodejs /app + +# Switch to non-root user +USER nodejs + +# Expose application port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \ + CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1 + +# Start the application +CMD ["node", "dist/server.js"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..e45ba73 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,94 @@ +# Feuerwehr Dashboard Backend + +## Description +Backend API for the Feuerwehr Dashboard application built with Node.js, Express, and TypeScript. + +## Tech Stack +- Node.js +- Express +- TypeScript +- PostgreSQL +- Winston (Logging) +- JWT (Authentication) +- Helmet (Security) +- Zod (Validation) + +## Prerequisites +- Node.js (v18 or higher) +- PostgreSQL (v14 or higher) +- npm or yarn + +## Installation + +```bash +npm install +``` + +## Configuration + +Create a `.env.development` file in the root directory: + +```env +NODE_ENV=development +PORT=3000 +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=feuerwehr_dev +DB_USER=dev_user +DB_PASSWORD=dev_password +JWT_SECRET=your-secret-key-change-in-production +JWT_EXPIRES_IN=24h +CORS_ORIGIN=http://localhost:3001 +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 +LOG_LEVEL=info +``` + +## Development + +```bash +# Run in development mode with hot reload +npm run dev + +# Build TypeScript to JavaScript +npm run build + +# Run production build +npm start +``` + +## Project Structure + +``` +backend/ +├── src/ +│ ├── config/ # Configuration files +│ ├── controllers/ # Route controllers +│ ├── database/ # Database migrations +│ ├── middleware/ # Express middleware +│ ├── models/ # Data models +│ ├── routes/ # API routes +│ ├── services/ # Business logic +│ ├── types/ # TypeScript types +│ ├── utils/ # Utility functions +│ ├── app.ts # Express app setup +│ └── server.ts # Server entry point +├── dist/ # Compiled JavaScript +├── logs/ # Application logs +└── package.json +``` + +## API Endpoints + +### Health Check +- `GET /health` - Server health status + +## Scripts + +- `npm run dev` - Start development server with hot reload +- `npm run build` - Build TypeScript to JavaScript +- `npm start` - Run production server +- `npm test` - Run tests (not yet implemented) + +## License +ISC diff --git a/backend/nodemon.json b/backend/nodemon.json new file mode 100644 index 0000000..352bd2d --- /dev/null +++ b/backend/nodemon.json @@ -0,0 +1,9 @@ +{ + "watch": ["src"], + "ext": "ts,json", + "ignore": ["src/**/*.spec.ts", "src/**/*.test.ts"], + "exec": "ts-node src/server.ts", + "env": { + "NODE_ENV": "development" + } +} diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..bbb623c --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2151 @@ +{ + "name": "backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backend", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "axios": "^1.13.5", + "cors": "^2.8.6", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", + "pg": "^8.18.0", + "winston": "^3.19.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^25.3.0", + "@types/pg": "^8.16.0", + "nodemon": "^3.1.14", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://npm.apple.com/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://npm.apple.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://npm.apple.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://npm.apple.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://npm.apple.com/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://npm.apple.com/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://npm.apple.com/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://npm.apple.com/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://npm.apple.com/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://npm.apple.com/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://npm.apple.com/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://npm.apple.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://npm.apple.com/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://npm.apple.com/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://npm.apple.com/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://npm.apple.com/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://npm.apple.com/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://npm.apple.com/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://npm.apple.com/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://npm.apple.com/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://npm.apple.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://npm.apple.com/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://npm.apple.com/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://npm.apple.com/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://npm.apple.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://npm.apple.com/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.3", + "resolved": "https://npm.apple.com/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://npm.apple.com/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://npm.apple.com/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://npm.apple.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://npm.apple.com/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://npm.apple.com/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://npm.apple.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://npm.apple.com/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://npm.apple.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://npm.apple.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://npm.apple.com/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://npm.apple.com/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://npm.apple.com/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://npm.apple.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://npm.apple.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://npm.apple.com/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://npm.apple.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://npm.apple.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://npm.apple.com/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://npm.apple.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://npm.apple.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://npm.apple.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://npm.apple.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://npm.apple.com/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://npm.apple.com/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://npm.apple.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://npm.apple.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://npm.apple.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://npm.apple.com/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://npm.apple.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://npm.apple.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://npm.apple.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://npm.apple.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://npm.apple.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://npm.apple.com/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://npm.apple.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://npm.apple.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://npm.apple.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://npm.apple.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://npm.apple.com/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://npm.apple.com/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://npm.apple.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://npm.apple.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://npm.apple.com/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://npm.apple.com/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://npm.apple.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://npm.apple.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://npm.apple.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://npm.apple.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://npm.apple.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://npm.apple.com/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://npm.apple.com/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://npm.apple.com/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://npm.apple.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.2", + "resolved": "https://npm.apple.com/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://npm.apple.com/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://npm.apple.com/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://npm.apple.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://npm.apple.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://npm.apple.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://npm.apple.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://npm.apple.com/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://npm.apple.com/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://npm.apple.com/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://npm.apple.com/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://npm.apple.com/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://npm.apple.com/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://npm.apple.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://npm.apple.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://npm.apple.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://npm.apple.com/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://npm.apple.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://npm.apple.com/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://npm.apple.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://npm.apple.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://npm.apple.com/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://npm.apple.com/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://npm.apple.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://npm.apple.com/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://npm.apple.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://npm.apple.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://npm.apple.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://npm.apple.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://npm.apple.com/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://npm.apple.com/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://npm.apple.com/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://npm.apple.com/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://npm.apple.com/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://npm.apple.com/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://npm.apple.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://npm.apple.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://npm.apple.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://npm.apple.com/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://npm.apple.com/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://npm.apple.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://npm.apple.com/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..3442bb3 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,38 @@ +{ + "name": "backend", + "version": "1.0.0", + "description": "Feuerwehr Dashboard Backend API", + "main": "dist/server.js", + "scripts": { + "dev": "nodemon", + "build": "tsc", + "start": "node dist/server.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "axios": "^1.13.5", + "cors": "^2.8.6", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", + "pg": "^8.18.0", + "winston": "^3.19.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^25.3.0", + "@types/pg": "^8.16.0", + "nodemon": "^3.1.14", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + } +} diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..4eb1d4c --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,68 @@ +import express, { Application, Request, Response } from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import environment from './config/environment'; +import logger from './utils/logger'; +import { errorHandler, notFoundHandler } from './middleware/error.middleware'; + +const app: Application = express(); + +// Security middleware +app.use(helmet()); + +// CORS configuration +app.use(cors({ + origin: environment.cors.origin, + credentials: true, +})); + +// Rate limiting +const limiter = rateLimit({ + windowMs: environment.rateLimit.windowMs, + max: environment.rateLimit.max, + message: 'Too many requests from this IP, please try again later.', + standardHeaders: true, + legacyHeaders: false, +}); + +app.use('/api', limiter); + +// Body parsing middleware +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Request logging middleware +app.use((req: Request, _res: Response, next) => { + logger.info('Incoming request', { + method: req.method, + path: req.path, + ip: req.ip, + }); + next(); +}); + +// Health check endpoint +app.get('/health', (_req: Request, res: Response) => { + res.status(200).json({ + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + environment: environment.nodeEnv, + }); +}); + +// API routes +import authRoutes from './routes/auth.routes'; +import userRoutes from './routes/user.routes'; + +app.use('/api/auth', authRoutes); +app.use('/api/user', userRoutes); + +// 404 handler +app.use(notFoundHandler); + +// Error handling middleware (must be last) +app.use(errorHandler); + +export default app; diff --git a/backend/src/config/authentik.ts b/backend/src/config/authentik.ts new file mode 100644 index 0000000..595f2cd --- /dev/null +++ b/backend/src/config/authentik.ts @@ -0,0 +1,25 @@ +import environment from './environment'; + +interface AuthentikConfig { + issuer: string; + clientId: string; + clientSecret: string; + redirectUri: string; + tokenEndpoint: string; + userInfoEndpoint: string; + authorizeEndpoint: string; + logoutEndpoint: string; +} + +const authentikConfig: AuthentikConfig = { + issuer: environment.authentik.issuer, + clientId: environment.authentik.clientId, + clientSecret: environment.authentik.clientSecret, + redirectUri: environment.authentik.redirectUri, + tokenEndpoint: `${environment.authentik.issuer}token/`, + userInfoEndpoint: `${environment.authentik.issuer}userinfo/`, + authorizeEndpoint: `${environment.authentik.issuer}authorize/`, + logoutEndpoint: `${environment.authentik.issuer}logout/`, +}; + +export default authentikConfig; diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts new file mode 100644 index 0000000..d884f8b --- /dev/null +++ b/backend/src/config/database.ts @@ -0,0 +1,145 @@ +import { Pool, PoolConfig } from 'pg'; +import * as fs from 'fs'; +import * as path from 'path'; +import environment from './environment'; +import logger from '../utils/logger'; + +const poolConfig: PoolConfig = { + host: environment.database.host, + port: environment.database.port, + database: environment.database.name, + user: environment.database.user, + password: environment.database.password, + max: 20, // Maximum number of clients in the pool + idleTimeoutMillis: 30000, // Close idle clients after 30 seconds + connectionTimeoutMillis: 2000, // Return an error if connection takes longer than 2 seconds +}; + +const pool = new Pool(poolConfig); + +// Handle pool errors +pool.on('error', (err) => { + logger.error('Unexpected error on idle database client', err); +}); + +// Test database connection +export const testConnection = async (): Promise => { + try { + const client = await pool.connect(); + const result = await client.query('SELECT NOW()'); + logger.info('Database connection successful', { timestamp: result.rows[0].now }); + client.release(); + return true; + } catch (error) { + logger.error('Failed to connect to database', { error }); + return false; + } +}; + +// Graceful shutdown +export const closePool = async (): Promise => { + try { + await pool.end(); + logger.info('Database pool closed'); + } catch (error) { + logger.error('Error closing database pool', { error }); + } +}; + +/** + * Check if a table exists in the database + */ +export const tableExists = async (tableName: string): Promise => { + try { + const result = await pool.query( + `SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + )`, + [tableName] + ); + return result.rows[0].exists; + } catch (error) { + logger.error(`Failed to check if table ${tableName} exists`, { error }); + return false; + } +}; + +/** + * Run database migrations from SQL files + */ +export const runMigrations = async (): Promise => { + const migrationsDir = path.join(__dirname, '../database/migrations'); + + try { + // Check if migrations directory exists + if (!fs.existsSync(migrationsDir)) { + logger.warn('Migrations directory not found', { path: migrationsDir }); + return; + } + + // Read all migration files + const files = fs.readdirSync(migrationsDir) + .filter(file => file.endsWith('.sql')) + .sort(); // Sort to ensure migrations run in order + + if (files.length === 0) { + logger.info('No migration files found'); + return; + } + + logger.info(`Found ${files.length} migration file(s)`); + + // Create migrations tracking table if it doesn't exist + await pool.query(` + CREATE TABLE IF NOT EXISTS migrations ( + id SERIAL PRIMARY KEY, + filename VARCHAR(255) UNIQUE NOT NULL, + executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ) + `); + + // Run each migration + for (const file of files) { + // Check if migration has already been run + const result = await pool.query( + 'SELECT filename FROM migrations WHERE filename = $1', + [file] + ); + + if (result.rows.length > 0) { + logger.info(`Migration ${file} already executed, skipping`); + continue; + } + + // Read and execute migration + const filePath = path.join(migrationsDir, file); + const sql = fs.readFileSync(filePath, 'utf8'); + + logger.info(`Running migration: ${file}`); + + await pool.query('BEGIN'); + try { + await pool.query(sql); + await pool.query( + 'INSERT INTO migrations (filename) VALUES ($1)', + [file] + ); + await pool.query('COMMIT'); + logger.info(`Migration ${file} completed successfully`); + } catch (error) { + await pool.query('ROLLBACK'); + logger.error(`Migration ${file} failed`, { error }); + throw error; + } + } + + logger.info('All migrations completed successfully'); + } catch (error) { + logger.error('Failed to run migrations', { error }); + throw error; + } +}; + +export default pool; diff --git a/backend/src/config/environment.ts b/backend/src/config/environment.ts new file mode 100644 index 0000000..1102a27 --- /dev/null +++ b/backend/src/config/environment.ts @@ -0,0 +1,69 @@ +import dotenv from 'dotenv'; +import path from 'path'; + +// Load environment-specific .env file +const envFile = process.env.NODE_ENV === 'production' + ? '.env.production' + : '.env.development'; + +dotenv.config({ path: path.resolve(__dirname, '../../', envFile) }); + +interface EnvironmentConfig { + nodeEnv: string; + port: number; + database: { + host: string; + port: number; + name: string; + user: string; + password: string; + }; + jwt: { + secret: string; + expiresIn: string | number; + }; + cors: { + origin: string; + }; + rateLimit: { + windowMs: number; + max: number; + }; + authentik: { + issuer: string; + clientId: string; + clientSecret: string; + redirectUri: string; + }; +} + +const environment: EnvironmentConfig = { + nodeEnv: process.env.NODE_ENV || 'development', + port: parseInt(process.env.PORT || '3000', 10), + database: { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + name: process.env.DB_NAME || 'feuerwehr_dev', + user: process.env.DB_USER || 'dev_user', + password: process.env.DB_PASSWORD || 'dev_password', + }, + jwt: { + secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', + expiresIn: process.env.JWT_EXPIRES_IN || '24h', + }, + cors: { + origin: process.env.CORS_ORIGIN || 'http://localhost:3001', + }, + rateLimit: { + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes + max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10), + }, + authentik: { + issuer: process.env.AUTHENTIK_ISSUER || 'https://authentik.yourdomain.com/application/o/your-app/', + clientId: process.env.AUTHENTIK_CLIENT_ID || 'your_client_id_here', + clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || 'your_client_secret_here', + redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://localhost:5173/auth/callback', + }, +}; + +export default environment; diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts new file mode 100644 index 0000000..600c811 --- /dev/null +++ b/backend/src/controllers/auth.controller.ts @@ -0,0 +1,246 @@ +import { Request, Response } from 'express'; +import authentikService from '../services/authentik.service'; +import tokenService from '../services/token.service'; +import userService from '../services/user.service'; +import logger from '../utils/logger'; +import { AuthRequest } from '../types/auth.types'; + +class AuthController { + /** + * Handle OAuth callback + * POST /api/auth/callback + */ + async handleCallback(req: Request, res: Response): Promise { + try { + const { code } = req.body as AuthRequest; + + // Validate code + if (!code) { + res.status(400).json({ + success: false, + message: 'Authorization code is required', + }); + return; + } + + logger.info('Processing OAuth callback', { hasCode: !!code }); + + // Step 1: Exchange code for tokens + const tokens = await authentikService.exchangeCodeForTokens(code); + + // Step 2: Get user info from Authentik + const userInfo = await authentikService.getUserInfo(tokens.access_token); + + // Step 3: Verify ID token if present + if (tokens.id_token) { + try { + authentikService.verifyIdToken(tokens.id_token); + } catch (error) { + logger.warn('ID token verification failed', { error }); + } + } + + // Step 4: Find or create user in database + let user = await userService.findByAuthentikSub(userInfo.sub); + + if (!user) { + // User doesn't exist, create new user + logger.info('Creating new user from Authentik', { + sub: userInfo.sub, + email: userInfo.email, + }); + + user = await userService.createUser({ + email: userInfo.email, + authentik_sub: userInfo.sub, + preferred_username: userInfo.preferred_username, + given_name: userInfo.given_name, + family_name: userInfo.family_name, + name: userInfo.name, + profile_picture_url: userInfo.picture, + }); + } else { + // User exists, update last login + logger.info('Existing user logging in', { + userId: user.id, + email: user.email, + }); + + await userService.updateLastLogin(user.id); + } + + // Check if user is active + if (!user.is_active) { + logger.warn('Inactive user attempted login', { userId: user.id }); + res.status(403).json({ + success: false, + message: 'User account is inactive', + }); + return; + } + + // Step 5: Generate internal JWT token + const accessToken = tokenService.generateToken({ + userId: user.id, + email: user.email, + authentikSub: user.authentik_sub, + }); + + // Generate refresh token + const refreshToken = tokenService.generateRefreshToken({ + userId: user.id, + email: user.email, + }); + + logger.info('User authenticated successfully', { + userId: user.id, + email: user.email, + }); + + // Step 6: Return tokens and user info + res.status(200).json({ + success: true, + message: 'Authentication successful', + data: { + accessToken, + refreshToken, + user: { + id: user.id, + email: user.email, + name: user.name, + preferredUsername: user.preferred_username, + givenName: user.given_name, + familyName: user.family_name, + profilePictureUrl: user.profile_picture_url, + isActive: user.is_active, + }, + }, + }); + } catch (error) { + logger.error('OAuth callback error', { error }); + + const message = + error instanceof Error ? error.message : 'Authentication failed'; + + res.status(500).json({ + success: false, + message, + }); + } + } + + /** + * Handle logout + * POST /api/auth/logout + */ + async handleLogout(req: Request, res: Response): Promise { + try { + // In a stateless JWT setup, logout is handled client-side by removing the token + // However, we can log the event for audit purposes + + if (req.user) { + logger.info('User logged out', { + userId: req.user.id, + email: req.user.email, + }); + } + + res.status(200).json({ + success: true, + message: 'Logout successful', + }); + } catch (error) { + logger.error('Logout error', { error }); + + res.status(500).json({ + success: false, + message: 'Logout failed', + }); + } + } + + /** + * Handle token refresh + * POST /api/auth/refresh + */ + async handleRefresh(req: Request, res: Response): Promise { + try { + const { refreshToken } = req.body; + + if (!refreshToken) { + res.status(400).json({ + success: false, + message: 'Refresh token is required', + }); + return; + } + + // Verify refresh token + let decoded; + try { + decoded = tokenService.verifyRefreshToken(refreshToken); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid refresh token'; + res.status(401).json({ + success: false, + message, + }); + return; + } + + // Get user from database + const user = await userService.findById(decoded.userId); + + if (!user) { + logger.warn('Refresh token valid but user not found', { + userId: decoded.userId, + }); + res.status(401).json({ + success: false, + message: 'User not found', + }); + return; + } + + if (!user.is_active) { + logger.warn('Inactive user attempted token refresh', { + userId: user.id, + }); + res.status(403).json({ + success: false, + message: 'User account is inactive', + }); + return; + } + + // Generate new access token + const accessToken = tokenService.generateToken({ + userId: user.id, + email: user.email, + authentikSub: user.authentik_sub, + }); + + logger.info('Token refreshed successfully', { + userId: user.id, + email: user.email, + }); + + res.status(200).json({ + success: true, + message: 'Token refreshed successfully', + data: { + accessToken, + }, + }); + } catch (error) { + logger.error('Token refresh error', { error }); + + res.status(500).json({ + success: false, + message: 'Token refresh failed', + }); + } + } +} + +export default new AuthController(); diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts new file mode 100644 index 0000000..612a4f5 --- /dev/null +++ b/backend/src/controllers/user.controller.ts @@ -0,0 +1,63 @@ +import { Request, Response } from 'express'; +import userService from '../services/user.service'; +import logger from '../utils/logger'; + +class UserController { + /** + * Get current user + * GET /api/user/me + */ + async getCurrentUser(req: Request, res: Response): Promise { + try { + // User is attached by auth middleware + if (!req.user) { + res.status(401).json({ + success: false, + message: 'Not authenticated', + }); + return; + } + + // Get full user details from database + const user = await userService.findById(req.user.id); + + if (!user) { + logger.warn('Authenticated user not found in database', { + userId: req.user.id, + }); + res.status(404).json({ + success: false, + message: 'User not found', + }); + return; + } + + logger.debug('Fetched current user', { userId: user.id }); + + res.status(200).json({ + success: true, + data: { + id: user.id, + email: user.email, + name: user.name, + preferredUsername: user.preferred_username, + givenName: user.given_name, + familyName: user.family_name, + profilePictureUrl: user.profile_picture_url, + isActive: user.is_active, + lastLoginAt: user.last_login_at, + createdAt: user.created_at, + }, + }); + } catch (error) { + logger.error('Get current user error', { error }); + + res.status(500).json({ + success: false, + message: 'Failed to fetch user information', + }); + } + } +} + +export default new UserController(); diff --git a/backend/src/database/migrations/001_create_users_table.sql b/backend/src/database/migrations/001_create_users_table.sql new file mode 100644 index 0000000..a223353 --- /dev/null +++ b/backend/src/database/migrations/001_create_users_table.sql @@ -0,0 +1,37 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + authentik_sub VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255), + preferred_username VARCHAR(255), + given_name VARCHAR(255), + family_name VARCHAR(255), + profile_picture_url TEXT, + + refresh_token TEXT, + refresh_token_expires_at TIMESTAMP WITH TIME ZONE, + + last_login_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + preferences JSONB DEFAULT '{}', + is_active BOOLEAN DEFAULT TRUE +); + +CREATE INDEX idx_users_authentik_sub ON users(authentik_sub); +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_last_login ON users(last_login_at); + +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/backend/src/database/migrations/README.md b/backend/src/database/migrations/README.md new file mode 100644 index 0000000..95e000b --- /dev/null +++ b/backend/src/database/migrations/README.md @@ -0,0 +1,223 @@ +# Database Migrations + +This directory contains SQL migration files for the Feuerwehr Dashboard database schema. + +## Overview + +Migrations are automatically executed when the application starts. Each migration file is tracked in the `migrations` table to ensure it only runs once. + +## Migration Files + +Migration files follow the naming convention: `{number}_{description}.sql` + +Example: +- `001_create_users_table.sql` +- `002_add_roles_table.sql` + +The numeric prefix determines the execution order. Always use sequential numbering. + +## How Migrations Work + +### Automatic Execution (Docker) + +When running the application with Docker, migrations are automatically executed during startup: + +1. Application starts +2. Database connection is established +3. `runMigrations()` function is called +4. Migration tracking table (`migrations`) is created if it doesn't exist +5. Each `.sql` file in this directory is checked: + - If already executed (recorded in `migrations` table): **skipped** + - If new: **executed within a transaction** +6. Successfully executed migrations are recorded in the tracking table + +### Migration Tracking + +The system creates a `migrations` table to track which migrations have been executed: + +```sql +CREATE TABLE migrations ( + id SERIAL PRIMARY KEY, + filename VARCHAR(255) UNIQUE NOT NULL, + executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +``` + +This ensures each migration runs exactly once, even across multiple deployments. + +## Manual Migration Execution + +If you need to run migrations manually (development or troubleshooting): + +### Using psql (PostgreSQL CLI) + +```bash +# Connect to the database +docker exec -it feuerwehr-postgres psql -U feuerwehr_user -d feuerwehr_db + +# Run a specific migration +\i /path/to/migration/001_create_users_table.sql + +# Or if inside the container +\i /docker-entrypoint-initdb.d/migrations/001_create_users_table.sql +``` + +### Using npm/node script + +Create a migration runner script: + +```bash +# From backend directory +npm run migrate +``` + +You can add this to `package.json`: + +```json +{ + "scripts": { + "migrate": "ts-node src/scripts/migrate.ts" + } +} +``` + +## Creating New Migrations + +1. **Determine the next migration number** + - Check existing files in this directory + - Use the next sequential number (e.g., if `001_` exists, create `002_`) + +2. **Create a new `.sql` file** + ```bash + touch src/database/migrations/002_add_new_feature.sql + ``` + +3. **Write your SQL schema changes** + ```sql + -- Always include IF EXISTS checks for safety + CREATE TABLE IF NOT EXISTS my_table ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + + -- Add indexes + CREATE INDEX IF NOT EXISTS idx_my_table_name ON my_table(name); + ``` + +4. **Restart the application** + - Docker will automatically detect and run the new migration + - Or call `runMigrations()` programmatically + +## Best Practices + +### 1. Make Migrations Idempotent + +Always use `IF EXISTS` or `IF NOT EXISTS` clauses: + +```sql +CREATE TABLE IF NOT EXISTS users (...); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +ALTER TABLE users ADD COLUMN IF NOT EXISTS new_column VARCHAR(255); +``` + +### 2. Use Transactions + +Each migration runs within a transaction automatically. If any part fails, the entire migration is rolled back. + +### 3. Never Modify Existing Migrations + +Once a migration has been deployed to production: +- **Never modify it** +- Create a new migration for changes +- This ensures consistency across all environments + +### 4. Test Migrations Locally + +Before deploying: +```bash +# Start fresh database +docker-compose down -v +docker-compose up -d postgres + +# Run migrations +npm run dev +# or +npm run migrate +``` + +### 5. Include Rollback Instructions + +Document how to revert changes in comments: + +```sql +-- Migration: Add user preferences column +-- Rollback: ALTER TABLE users DROP COLUMN preferences; + +ALTER TABLE users ADD COLUMN preferences JSONB DEFAULT '{}'; +``` + +## Troubleshooting + +### Migration Failed + +If a migration fails: + +1. **Check the logs** for error details +2. **Fix the SQL** in the migration file +3. **Remove the failed entry** from the tracking table: + ```sql + DELETE FROM migrations WHERE filename = '002_failed_migration.sql'; + ``` +4. **Restart the application** or re-run migrations + +### Reset All Migrations + +For development only (destroys all data): + +```bash +# Drop and recreate database +docker-compose down -v +docker-compose up -d postgres + +# Migrations will run automatically on next start +docker-compose up backend +``` + +### Check Migration Status + +```sql +-- See which migrations have been executed +SELECT * FROM migrations ORDER BY executed_at DESC; + +-- Check if a specific table exists +SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'users' +); +``` + +## Current Migrations + +### 001_create_users_table.sql + +Creates the main `users` table for storing authenticated user data: + +- **UUID primary key** with automatic generation +- **Authentik integration** fields (sub, email, profile data) +- **Token management** (refresh_token, expiration) +- **Audit fields** (last_login, created_at, updated_at) +- **User preferences** as JSONB +- **Active status** flag +- **Indexes** for performance: + - `authentik_sub` (unique identifier from OIDC) + - `email` (for lookups) + - `last_login_at` (for activity tracking) +- **Automatic timestamp updates** via trigger + +## References + +- [PostgreSQL Documentation](https://www.postgresql.org/docs/) +- [pg (node-postgres) Library](https://node-postgres.com/) +- Application database config: `src/config/database.ts` diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..02db570 --- /dev/null +++ b/backend/src/middleware/auth.middleware.ts @@ -0,0 +1,155 @@ +import { Request, Response, NextFunction } from 'express'; +import tokenService from '../services/token.service'; +import userService from '../services/user.service'; +import logger from '../utils/logger'; +import { JwtPayload } from '../types/auth.types'; + +// Extend Express Request type to include user +declare global { + namespace Express { + interface Request { + user?: { + id: string; // UUID + email: string; + authentikSub: string; + }; + } + } +} + +/** + * Authentication middleware + * Validates JWT token and attaches user info to request + */ +export const authenticate = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + // Extract token from Authorization header + const authHeader = req.headers.authorization; + + if (!authHeader) { + res.status(401).json({ + success: false, + message: 'No authorization token provided', + }); + return; + } + + // Check for Bearer token format + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + res.status(401).json({ + success: false, + message: 'Invalid authorization header format. Use: Bearer ', + }); + return; + } + + const token = parts[1]; + + // Verify token + let decoded: JwtPayload; + try { + decoded = tokenService.verifyToken(token); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid token'; + res.status(401).json({ + success: false, + message, + }); + return; + } + + // Check if user exists and is active + const user = await userService.findById(decoded.userId); + + if (!user) { + logger.warn('Token valid but user not found', { userId: decoded.userId }); + res.status(401).json({ + success: false, + message: 'User not found', + }); + return; + } + + if (!user.is_active) { + logger.warn('User account is inactive', { userId: decoded.userId }); + res.status(403).json({ + success: false, + message: 'User account is inactive', + }); + return; + } + + // Attach user info to request + req.user = { + id: decoded.userId, + email: decoded.email, + authentikSub: decoded.authentikSub, + }; + + logger.debug('User authenticated successfully', { + userId: decoded.userId, + email: decoded.email, + }); + + next(); + } catch (error) { + logger.error('Authentication middleware error', { error }); + res.status(500).json({ + success: false, + message: 'Internal server error during authentication', + }); + } +}; + +/** + * Optional authentication middleware + * Attaches user if token is valid, but doesn't require it + */ +export const optionalAuth = async ( + req: Request, + _res: Response, + next: NextFunction +): Promise => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader) { + next(); + return; + } + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + next(); + return; + } + + const token = parts[1]; + + try { + const decoded = tokenService.verifyToken(token); + const user = await userService.findById(decoded.userId); + + if (user && user.is_active) { + req.user = { + id: decoded.userId, + email: decoded.email, + authentikSub: decoded.authentikSub, + }; + } + } catch (error) { + // Invalid token - continue without user + logger.debug('Optional auth: Invalid token', { error }); + } + + next(); + } catch (error) { + logger.error('Optional authentication middleware error', { error }); + next(); + } +}; diff --git a/backend/src/middleware/error.middleware.ts b/backend/src/middleware/error.middleware.ts new file mode 100644 index 0000000..5dc693c --- /dev/null +++ b/backend/src/middleware/error.middleware.ts @@ -0,0 +1,66 @@ +import { Request, Response, NextFunction } from 'express'; +import logger from '../utils/logger'; + +export class AppError extends Error { + statusCode: number; + isOperational: boolean; + + constructor(message: string, statusCode: number = 500) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + + Error.captureStackTrace(this, this.constructor); + } +} + +export const errorHandler = ( + err: Error | AppError, + req: Request, + res: Response, + _next: NextFunction +): void => { + if (err instanceof AppError) { + logger.error('Application Error', { + message: err.message, + statusCode: err.statusCode, + stack: err.stack, + path: req.path, + method: req.method, + }); + + res.status(err.statusCode).json({ + status: 'error', + message: err.message, + }); + return; + } + + // Handle unexpected errors + logger.error('Unexpected Error', { + message: err.message, + stack: err.stack, + path: req.path, + method: req.method, + }); + + res.status(500).json({ + status: 'error', + message: process.env.NODE_ENV === 'production' + ? 'Internal server error' + : err.message, + }); +}; + +export const notFoundHandler = (req: Request, res: Response): void => { + res.status(404).json({ + status: 'error', + message: `Route ${req.originalUrl} not found`, + }); +}; + +export const asyncHandler = (fn: Function) => { + return (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; diff --git a/backend/src/models/user.model.ts b/backend/src/models/user.model.ts new file mode 100644 index 0000000..ec4bf75 --- /dev/null +++ b/backend/src/models/user.model.ts @@ -0,0 +1,37 @@ +export interface User { + id: string; // UUID + email: string; + authentik_sub: string; + name?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + profile_picture_url?: string; + refresh_token?: string; + refresh_token_expires_at?: Date; + is_active: boolean; + last_login_at?: Date; + created_at: Date; + updated_at: Date; + preferences?: any; // JSONB +} + +export interface CreateUserData { + email: string; + authentik_sub: string; + name?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + profile_picture_url?: string; +} + +export interface UpdateUserData { + name?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + profile_picture_url?: string; + is_active?: boolean; + preferences?: any; +} diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts new file mode 100644 index 0000000..090e3cc --- /dev/null +++ b/backend/src/routes/auth.routes.ts @@ -0,0 +1,28 @@ +import { Router } from 'express'; +import authController from '../controllers/auth.controller'; +import { optionalAuth } from '../middleware/auth.middleware'; + +const router = Router(); + +/** + * @route POST /api/auth/callback + * @desc Handle OAuth callback from Authentik + * @access Public + */ +router.post('/callback', authController.handleCallback); + +/** + * @route POST /api/auth/logout + * @desc Logout user + * @access Public (optional auth for logging purposes) + */ +router.post('/logout', optionalAuth, authController.handleLogout); + +/** + * @route POST /api/auth/refresh + * @desc Refresh access token + * @access Public + */ +router.post('/refresh', authController.handleRefresh); + +export default router; diff --git a/backend/src/routes/user.routes.ts b/backend/src/routes/user.routes.ts new file mode 100644 index 0000000..5cb58e0 --- /dev/null +++ b/backend/src/routes/user.routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import userController from '../controllers/user.controller'; +import { authenticate } from '../middleware/auth.middleware'; + +const router = Router(); + +/** + * @route GET /api/user/me + * @desc Get current authenticated user + * @access Private + */ +router.get('/me', authenticate, userController.getCurrentUser); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..ef9ef7a --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,73 @@ +import app from './app'; +import environment from './config/environment'; +import logger from './utils/logger'; +import { testConnection, closePool } from './config/database'; + +const startServer = async (): Promise => { + try { + // Test database connection + logger.info('Testing database connection...'); + const dbConnected = await testConnection(); + + if (!dbConnected) { + logger.warn('Database connection failed - server will start but database operations may fail'); + } + + // Start the server + const server = app.listen(environment.port, () => { + logger.info('Server started successfully', { + port: environment.port, + environment: environment.nodeEnv, + database: dbConnected ? 'connected' : 'disconnected', + }); + }); + + // Graceful shutdown handling + const gracefulShutdown = async (signal: string) => { + logger.info(`${signal} received. Starting graceful shutdown...`); + + server.close(async () => { + logger.info('HTTP server closed'); + + // Close database connection + await closePool(); + + logger.info('Graceful shutdown completed'); + process.exit(0); + }); + + // Force shutdown after 10 seconds + setTimeout(() => { + logger.error('Forced shutdown after timeout'); + process.exit(1); + }, 10000); + }; + + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + process.on('SIGINT', () => gracefulShutdown('SIGINT')); + + } catch (error) { + logger.error('Failed to start server', { error }); + process.exit(1); + } +}; + +// Handle unhandled promise rejections +process.on('unhandledRejection', (reason: Error, _promise: Promise) => { + logger.error('Unhandled Promise Rejection', { + reason: reason.message, + stack: reason.stack, + }); +}); + +// Handle uncaught exceptions +process.on('uncaughtException', (error: Error) => { + logger.error('Uncaught Exception', { + message: error.message, + stack: error.stack, + }); + process.exit(1); +}); + +// Start the server +startServer(); diff --git a/backend/src/services/authentik.service.ts b/backend/src/services/authentik.service.ts new file mode 100644 index 0000000..56fc1ba --- /dev/null +++ b/backend/src/services/authentik.service.ts @@ -0,0 +1,158 @@ +import axios, { AxiosError } from 'axios'; +import authentikConfig from '../config/authentik'; +import logger from '../utils/logger'; +import { TokenResponse, UserInfo } from '../types/auth.types'; + +class AuthentikService { + /** + * Exchange authorization code for access and ID tokens + */ + async exchangeCodeForTokens(code: string): Promise { + try { + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: authentikConfig.redirectUri, + client_id: authentikConfig.clientId, + client_secret: authentikConfig.clientSecret, + }); + + const response = await axios.post( + authentikConfig.tokenEndpoint, + params.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); + + logger.info('Successfully exchanged code for tokens'); + return response.data; + } catch (error) { + this.handleError(error, 'Failed to exchange code for tokens'); + throw error; + } + } + + /** + * Fetch user information from Authentik using access token + */ + async getUserInfo(accessToken: string): Promise { + try { + const response = await axios.get( + authentikConfig.userInfoEndpoint, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + logger.info('Successfully fetched user info', { + sub: response.data.sub, + email: response.data.email, + }); + return response.data; + } catch (error) { + this.handleError(error, 'Failed to fetch user info'); + throw error; + } + } + + /** + * Verify and decode ID token (basic validation) + * Note: For production, use a proper JWT verification library like jose or jsonwebtoken + */ + verifyIdToken(idToken: string): any { + try { + // Split the token into parts + const parts = idToken.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid ID token format'); + } + + // Decode the payload (Base64URL) + const payload = JSON.parse( + Buffer.from(parts[1], 'base64url').toString('utf-8') + ); + + // Basic validation + if (!payload.sub || !payload.email) { + throw new Error('Invalid ID token payload'); + } + + // Check expiration + if (payload.exp && payload.exp * 1000 < Date.now()) { + throw new Error('ID token has expired'); + } + + // Check issuer + if (payload.iss && !payload.iss.includes(authentikConfig.issuer)) { + logger.warn('ID token issuer mismatch', { + expected: authentikConfig.issuer, + received: payload.iss, + }); + } + + logger.info('ID token verified successfully', { + sub: payload.sub, + email: payload.email, + }); + + return payload; + } catch (error) { + logger.error('Failed to verify ID token', { error }); + throw new Error('Invalid ID token'); + } + } + + /** + * Refresh access token using refresh token + */ + async refreshAccessToken(refreshToken: string): Promise { + try { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: authentikConfig.clientId, + client_secret: authentikConfig.clientSecret, + }); + + const response = await axios.post( + authentikConfig.tokenEndpoint, + params.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); + + logger.info('Successfully refreshed access token'); + return response.data; + } catch (error) { + this.handleError(error, 'Failed to refresh access token'); + throw error; + } + } + + /** + * Handle axios errors with detailed logging + */ + private handleError(error: unknown, message: string): void { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + logger.error(message, { + status: axiosError.response?.status, + statusText: axiosError.response?.statusText, + data: axiosError.response?.data, + message: axiosError.message, + }); + } else { + logger.error(message, { error }); + } + } +} + +export default new AuthentikService(); diff --git a/backend/src/services/token.service.ts b/backend/src/services/token.service.ts new file mode 100644 index 0000000..a0cb5e6 --- /dev/null +++ b/backend/src/services/token.service.ts @@ -0,0 +1,122 @@ +import jwt from 'jsonwebtoken'; +import environment from '../config/environment'; +import logger from '../utils/logger'; +import { JwtPayload, RefreshTokenPayload } from '../types/auth.types'; + +class TokenService { + /** + * Generate JWT access token + */ + generateToken(payload: JwtPayload): string { + try { + const token = jwt.sign( + { + userId: payload.userId, + email: payload.email, + authentikSub: payload.authentikSub, + }, + environment.jwt.secret, + { + expiresIn: environment.jwt.expiresIn as any, + } + ); + + logger.info('Generated JWT token', { userId: payload.userId }); + return token; + } catch (error) { + logger.error('Failed to generate JWT token', { error }); + throw new Error('Token generation failed'); + } + } + + /** + * Verify and decode JWT token + */ + verifyToken(token: string): JwtPayload { + try { + const decoded = jwt.verify( + token, + environment.jwt.secret + ) as JwtPayload; + + logger.debug('JWT token verified', { userId: decoded.userId }); + return decoded; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + logger.warn('JWT token expired'); + throw new Error('Token expired'); + } else if (error instanceof jwt.JsonWebTokenError) { + logger.warn('Invalid JWT token', { error: error.message }); + throw new Error('Invalid token'); + } else { + logger.error('Failed to verify JWT token', { error }); + throw new Error('Token verification failed'); + } + } + } + + /** + * Generate refresh token (longer lived) + */ + generateRefreshToken(payload: RefreshTokenPayload): string { + try { + const token = jwt.sign( + { + userId: payload.userId, + email: payload.email, + }, + environment.jwt.secret, + { + expiresIn: '7d', // Refresh tokens valid for 7 days + } + ); + + logger.info('Generated refresh token', { userId: payload.userId }); + return token; + } catch (error) { + logger.error('Failed to generate refresh token', { error }); + throw new Error('Refresh token generation failed'); + } + } + + /** + * Verify refresh token + */ + verifyRefreshToken(token: string): RefreshTokenPayload { + try { + const decoded = jwt.verify( + token, + environment.jwt.secret + ) as RefreshTokenPayload; + + logger.debug('Refresh token verified', { userId: decoded.userId }); + return decoded; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + logger.warn('Refresh token expired'); + throw new Error('Refresh token expired'); + } else if (error instanceof jwt.JsonWebTokenError) { + logger.warn('Invalid refresh token', { error: error.message }); + throw new Error('Invalid refresh token'); + } else { + logger.error('Failed to verify refresh token', { error }); + throw new Error('Refresh token verification failed'); + } + } + } + + /** + * Decode token without verification (for debugging) + */ + decodeToken(token: string): JwtPayload | null { + try { + const decoded = jwt.decode(token) as JwtPayload; + return decoded; + } catch (error) { + logger.error('Failed to decode token', { error }); + return null; + } + } +} + +export default new TokenService(); diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts new file mode 100644 index 0000000..bb47cf3 --- /dev/null +++ b/backend/src/services/user.service.ts @@ -0,0 +1,275 @@ +import pool from '../config/database'; +import logger from '../utils/logger'; +import { User, CreateUserData, UpdateUserData } from '../models/user.model'; + +class UserService { + /** + * Find user by Authentik sub (subject identifier) + */ + async findByAuthentikSub(sub: string): Promise { + try { + const query = ` + SELECT id, email, authentik_sub, name, preferred_username, given_name, + family_name, profile_picture_url, refresh_token, refresh_token_expires_at, + is_active, last_login_at, created_at, updated_at, preferences + FROM users + WHERE authentik_sub = $1 + `; + + const result = await pool.query(query, [sub]); + + if (result.rows.length === 0) { + logger.debug('User not found by Authentik sub', { sub }); + return null; + } + + logger.debug('User found by Authentik sub', { sub, userId: result.rows[0].id }); + return result.rows[0] as User; + } catch (error) { + logger.error('Error finding user by Authentik sub', { error, sub }); + throw new Error('Database query failed'); + } + } + + /** + * Find user by email + */ + async findByEmail(email: string): Promise { + try { + const query = ` + SELECT id, email, authentik_sub, name, preferred_username, given_name, + family_name, profile_picture_url, refresh_token, refresh_token_expires_at, + is_active, last_login_at, created_at, updated_at, preferences + FROM users + WHERE email = $1 + `; + + const result = await pool.query(query, [email]); + + if (result.rows.length === 0) { + logger.debug('User not found by email', { email }); + return null; + } + + logger.debug('User found by email', { email, userId: result.rows[0].id }); + return result.rows[0] as User; + } catch (error) { + logger.error('Error finding user by email', { error, email }); + throw new Error('Database query failed'); + } + } + + /** + * Find user by ID + */ + async findById(id: string): Promise { + try { + const query = ` + SELECT id, email, authentik_sub, name, preferred_username, given_name, + family_name, profile_picture_url, refresh_token, refresh_token_expires_at, + is_active, last_login_at, created_at, updated_at, preferences + FROM users + WHERE id = $1 + `; + + const result = await pool.query(query, [id]); + + if (result.rows.length === 0) { + logger.debug('User not found by ID', { id }); + return null; + } + + logger.debug('User found by ID', { id }); + return result.rows[0] as User; + } catch (error) { + logger.error('Error finding user by ID', { error, id }); + throw new Error('Database query failed'); + } + } + + /** + * Create a new user + */ + async createUser(userData: CreateUserData): Promise { + try { + const query = ` + INSERT INTO users ( + email, + authentik_sub, + name, + preferred_username, + given_name, + family_name, + profile_picture_url, + is_active + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, true) + RETURNING id, email, authentik_sub, name, preferred_username, given_name, + family_name, profile_picture_url, refresh_token, refresh_token_expires_at, + is_active, last_login_at, created_at, updated_at, preferences + `; + + const values = [ + userData.email, + userData.authentik_sub, + userData.name || null, + userData.preferred_username || null, + userData.given_name || null, + userData.family_name || null, + userData.profile_picture_url || null, + ]; + + const result = await pool.query(query, values); + + const user = result.rows[0] as User; + logger.info('User created successfully', { + userId: user.id, + email: user.email, + }); + + return user; + } catch (error) { + logger.error('Error creating user', { error, email: userData.email }); + throw new Error('Failed to create user'); + } + } + + /** + * Update user information + */ + async updateUser(id: string, data: UpdateUserData): Promise { + try { + const updateFields: string[] = []; + const values: any[] = []; + let paramCount = 1; + + if (data.name !== undefined) { + updateFields.push(`name = $${paramCount++}`); + values.push(data.name); + } + if (data.preferred_username !== undefined) { + updateFields.push(`preferred_username = $${paramCount++}`); + values.push(data.preferred_username); + } + if (data.given_name !== undefined) { + updateFields.push(`given_name = $${paramCount++}`); + values.push(data.given_name); + } + if (data.family_name !== undefined) { + updateFields.push(`family_name = $${paramCount++}`); + values.push(data.family_name); + } + if (data.profile_picture_url !== undefined) { + updateFields.push(`profile_picture_url = $${paramCount++}`); + values.push(data.profile_picture_url); + } + if (data.is_active !== undefined) { + updateFields.push(`is_active = $${paramCount++}`); + values.push(data.is_active); + } + if (data.preferences !== undefined) { + updateFields.push(`preferences = $${paramCount++}`); + values.push(JSON.stringify(data.preferences)); + } + + if (updateFields.length === 0) { + throw new Error('No fields to update'); + } + + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + values.push(id); + + const query = ` + UPDATE users + SET ${updateFields.join(', ')} + WHERE id = $${paramCount} + RETURNING id, email, authentik_sub, name, preferred_username, given_name, + family_name, profile_picture_url, refresh_token, refresh_token_expires_at, + is_active, last_login_at, created_at, updated_at, preferences + `; + + const result = await pool.query(query, values); + + if (result.rows.length === 0) { + throw new Error('User not found'); + } + + const user = result.rows[0] as User; + logger.info('User updated successfully', { userId: user.id }); + + return user; + } catch (error) { + logger.error('Error updating user', { error, userId: id }); + throw new Error('Failed to update user'); + } + } + + /** + * Update last login timestamp + */ + async updateLastLogin(id: string): Promise { + try { + const query = ` + UPDATE users + SET last_login_at = CURRENT_TIMESTAMP + WHERE id = $1 + `; + + await pool.query(query, [id]); + logger.debug('Updated last login timestamp', { userId: id }); + } catch (error) { + logger.error('Error updating last login', { error, userId: id }); + // Don't throw - this is not critical + } + } + + /** + * Update refresh token + */ + async updateRefreshToken( + id: string, + refreshToken: string | null, + expiresAt: Date | null + ): Promise { + try { + const query = ` + UPDATE users + SET refresh_token = $1, + refresh_token_expires_at = $2 + WHERE id = $3 + `; + + await pool.query(query, [refreshToken, expiresAt, id]); + logger.debug('Updated refresh token', { userId: id }); + } catch (error) { + logger.error('Error updating refresh token', { error, userId: id }); + throw new Error('Failed to update refresh token'); + } + } + + /** + * Check if user is active + */ + async isUserActive(id: string): Promise { + try { + const query = ` + SELECT is_active + FROM users + WHERE id = $1 + `; + + const result = await pool.query(query, [id]); + + if (result.rows.length === 0) { + return false; + } + + return result.rows[0].is_active; + } catch (error) { + logger.error('Error checking user active status', { error, userId: id }); + return false; + } + } +} + +export default new UserService(); diff --git a/backend/src/types/auth.types.ts b/backend/src/types/auth.types.ts new file mode 100644 index 0000000..4809495 --- /dev/null +++ b/backend/src/types/auth.types.ts @@ -0,0 +1,50 @@ +export interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; + id_token?: string; +} + +export interface UserInfo { + sub: string; + email: string; + email_verified?: boolean; + name?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + picture?: string; + groups?: string[]; +} + +export interface AuthRequest { + code: string; + state?: string; +} + +export interface JwtPayload { + userId: string; // UUID + email: string; + authentikSub: string; + iat?: number; + exp?: number; +} + +export interface RefreshTokenPayload { + userId: string; // UUID + email: string; + iat?: number; + exp?: number; +} + +export interface AuthenticatedUser { + id: string; // UUID + email: string; + authentikSub: string; + name?: string; + username?: string; + firstName?: string; + lastName?: string; + isActive: boolean; +} diff --git a/backend/src/types/user.types.ts b/backend/src/types/user.types.ts new file mode 100644 index 0000000..b29dbc1 --- /dev/null +++ b/backend/src/types/user.types.ts @@ -0,0 +1,92 @@ +/** + * User entity matching database schema + */ +export interface User { + id: string; + authentik_sub: string; + email: string; + name: string | null; + preferred_username: string | null; + given_name: string | null; + family_name: string | null; + profile_picture_url: string | null; + + refresh_token: string | null; + refresh_token_expires_at: Date | null; + + last_login_at: Date | null; + created_at: Date; + updated_at: Date; + + preferences: Record; + is_active: boolean; +} + +/** + * DTO for creating a new user + */ +export interface CreateUserDTO { + authentik_sub: string; + email: string; + name?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + profile_picture_url?: string; + preferences?: Record; +} + +/** + * DTO for updating an existing user + */ +export interface UpdateUserDTO { + name?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + profile_picture_url?: string; + refresh_token?: string | null; + refresh_token_expires_at?: Date | null; + last_login_at?: Date; + preferences?: Record; + is_active?: boolean; +} + +/** + * User response without sensitive fields + * Used for API responses + */ +export interface UserResponse { + id: string; + email: string; + name: string | null; + preferred_username: string | null; + given_name: string | null; + family_name: string | null; + profile_picture_url: string | null; + last_login_at: Date | null; + created_at: Date; + updated_at: Date; + preferences: Record; + is_active: boolean; +} + +/** + * Convert User to UserResponse by removing sensitive fields + */ +export function toUserResponse(user: User): UserResponse { + return { + id: user.id, + email: user.email, + name: user.name, + preferred_username: user.preferred_username, + given_name: user.given_name, + family_name: user.family_name, + profile_picture_url: user.profile_picture_url, + last_login_at: user.last_login_at, + created_at: user.created_at, + updated_at: user.updated_at, + preferences: user.preferences, + is_active: user.is_active, + }; +} diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts new file mode 100644 index 0000000..b410931 --- /dev/null +++ b/backend/src/utils/logger.ts @@ -0,0 +1,58 @@ +import winston from 'winston'; +import path from 'path'; + +const logDir = path.join(__dirname, '../../logs'); + +// Define log format +const logFormat = winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.splat(), + winston.format.json() +); + +// Console format for development +const consoleFormat = winston.format.combine( + winston.format.colorize(), + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.printf(({ timestamp, level, message, ...meta }) => { + let metaString = ''; + if (Object.keys(meta).length > 0) { + metaString = JSON.stringify(meta, null, 2); + } + return `${timestamp} [${level}]: ${message} ${metaString}`; + }) +); + +// Create the logger +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: logFormat, + defaultMeta: { service: 'feuerwehr-dashboard-api' }, + transports: [ + // Write all logs with importance level of 'error' or less to error.log + new winston.transports.File({ + filename: path.join(logDir, 'error.log'), + level: 'error', + maxsize: 5242880, // 5MB + maxFiles: 5, + }), + // Write all logs to combined.log + new winston.transports.File({ + filename: path.join(logDir, 'combined.log'), + maxsize: 5242880, // 5MB + maxFiles: 5, + }), + ], +}); + +// If not in production, log to the console as well +if (process.env.NODE_ENV !== 'production') { + logger.add( + new winston.transports.Console({ + format: consoleFormat, + }) + ); +} + +export default logger; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..4d8bfe8 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..7230e5b --- /dev/null +++ b/deploy.sh @@ -0,0 +1,173 @@ +#!/bin/bash + +# Feuerwehr Dashboard Deployment Script +# Usage: ./deploy.sh [production|local|stop|logs|rebuild|clean] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Helper functions +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if .env exists for production +check_env_file() { + if [ ! -f .env ]; then + log_error ".env file not found!" + log_info "Please copy .env.example to .env and configure it:" + log_info " cp .env.example .env" + exit 1 + fi +} + +# Production deployment +deploy_production() { + log_info "Starting production deployment..." + check_env_file + + log_info "Building and starting production services..." + docker-compose -f docker-compose.yml up -d --build + + log_info "Production deployment complete!" + log_info "Services are running:" + docker-compose -f docker-compose.yml ps +} + +# Local development +deploy_local() { + log_info "Starting local development environment..." + + log_info "Starting PostgreSQL database..." + docker-compose -f docker-compose.dev.yml up -d + + log_info "Local development database is ready!" + log_info "Connection details:" + log_info " Host: localhost" + log_info " Port: 5432" + log_info " Database: feuerwehr_dev" + log_info " User: dev_user" + log_info " Password: dev_password" +} + +# Stop services +stop_services() { + log_info "Stopping services..." + + if [ -f docker-compose.yml ]; then + docker-compose -f docker-compose.yml down + log_info "Production services stopped" + fi + + if [ -f docker-compose.dev.yml ]; then + docker-compose -f docker-compose.dev.yml down + log_info "Development services stopped" + fi +} + +# Show logs +show_logs() { + local compose_file="${1:-docker-compose.yml}" + + if [ ! -f "$compose_file" ]; then + log_error "Compose file not found: $compose_file" + exit 1 + fi + + log_info "Showing logs from $compose_file..." + docker-compose -f "$compose_file" logs -f +} + +# Rebuild services +rebuild_services() { + local compose_file="${1:-docker-compose.yml}" + + if [ ! -f "$compose_file" ]; then + log_error "Compose file not found: $compose_file" + exit 1 + fi + + log_info "Rebuilding services from $compose_file..." + docker-compose -f "$compose_file" up -d --build --force-recreate + + log_info "Rebuild complete!" +} + +# Clean up +clean_all() { + log_warn "This will remove all containers, volumes, and images for this project." + read -p "Are you sure? (y/N) " -n 1 -r + echo + + if [[ $REPLY =~ ^[Yy]$ ]]; then + log_info "Cleaning up..." + + # Stop all services + docker-compose -f docker-compose.yml down -v 2>/dev/null || true + docker-compose -f docker-compose.dev.yml down -v 2>/dev/null || true + + # Remove project-specific images + docker images | grep feuerwehr | awk '{print $3}' | xargs docker rmi -f 2>/dev/null || true + + log_info "Cleanup complete!" + else + log_info "Cleanup cancelled" + fi +} + +# Main script +case "${1:-}" in + production) + deploy_production + ;; + local) + deploy_local + ;; + stop) + stop_services + ;; + logs) + show_logs "${2:-docker-compose.yml}" + ;; + rebuild) + rebuild_services "${2:-docker-compose.yml}" + ;; + clean) + clean_all + ;; + *) + echo "Usage: $0 {production|local|stop|logs|rebuild|clean}" + echo "" + echo "Commands:" + echo " production - Deploy production environment (all services)" + echo " local - Start local development database only" + echo " stop - Stop all services" + echo " logs - Show logs (append 'docker-compose.dev.yml' for dev logs)" + echo " rebuild - Rebuild and restart services" + echo " clean - Remove all containers, volumes, and images" + echo "" + echo "Examples:" + echo " $0 local # Start dev database" + echo " $0 production # Deploy production" + echo " $0 logs # Show production logs" + echo " $0 logs docker-compose.dev.yml # Show dev logs" + echo " $0 rebuild docker-compose.dev.yml # Rebuild dev services" + exit 1 + ;; +esac diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..d6c7867 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: feuerwehr_db_dev + environment: + POSTGRES_DB: feuerwehr_dev + POSTGRES_USER: dev_user + POSTGRES_PASSWORD: dev_password + ports: + - "5432:5432" + volumes: + - postgres_data_dev:/var/lib/postgresql/data + - ./backend/src/database/migrations:/docker-entrypoint-initdb.d:ro + networks: + - feuerwehr_dev_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dev_user -d feuerwehr_dev"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + +volumes: + postgres_data_dev: + driver: local + +networks: + feuerwehr_dev_network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6ee5bcb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,82 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: feuerwehr_db_prod + environment: + POSTGRES_DB: ${POSTGRES_DB:-feuerwehr_prod} + POSTGRES_USER: ${POSTGRES_USER:-prod_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data_prod:/var/lib/postgresql/data + - ./backend/src/database/migrations:/docker-entrypoint-initdb.d:ro + networks: + - feuerwehr_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-prod_user} -d ${POSTGRES_DB:-feuerwehr_prod}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: feuerwehr_backend_prod + environment: + NODE_ENV: production + DATABASE_URL: postgresql://${POSTGRES_USER:-prod_user}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-feuerwehr_prod} + PORT: 3000 + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:80} + ports: + - "${BACKEND_PORT:-3000}:3000" + depends_on: + postgres: + condition: service_healthy + networks: + - feuerwehr_network + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_URL: ${VITE_API_URL:-http://localhost:3000} + container_name: feuerwehr_frontend_prod + environment: + VITE_API_URL: ${VITE_API_URL:-http://localhost:3000} + ports: + - "${FRONTEND_PORT:-80}:80" + depends_on: + backend: + condition: service_healthy + networks: + - feuerwehr_network + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + restart: unless-stopped + +volumes: + postgres_data_prod: + driver: local + +networks: + feuerwehr_network: + driver: bridge diff --git a/docker-test.sh b/docker-test.sh new file mode 100755 index 0000000..3f55b10 --- /dev/null +++ b/docker-test.sh @@ -0,0 +1,250 @@ +#!/bin/bash + +# docker-test.sh - Test Docker builds for Feuerwehr Dashboard +# This script tests building backend and frontend Docker images locally + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Log functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Print header +print_header() { + echo "" + echo "========================================" + echo " Feuerwehr Dashboard - Docker Build Test" + echo "========================================" + echo "" +} + +# Check if Docker is available +check_docker() { + log_info "Checking Docker availability..." + + if ! command -v docker &> /dev/null; then + log_error "Docker is not installed or not in PATH" + log_info "Please install Docker from https://docs.docker.com/get-docker/" + exit 1 + fi + + if ! docker info &> /dev/null; then + log_error "Docker daemon is not running" + log_info "Please start Docker and try again" + exit 1 + fi + + log_success "Docker is available" + docker --version + echo "" +} + +# Test backend Docker build +test_backend_build() { + log_info "Testing Backend Docker build..." + echo "" + + cd "${SCRIPT_DIR}/backend" + + # Check if Dockerfile exists + if [ ! -f "Dockerfile" ]; then + log_error "Backend Dockerfile not found at ${SCRIPT_DIR}/backend/Dockerfile" + return 1 + fi + + # Check if package.json exists + if [ ! -f "package.json" ]; then + log_error "Backend package.json not found" + return 1 + fi + + log_info "Building backend image (feuerwehr-backend-test)..." + + # Build the image + if docker build \ + --tag feuerwehr-backend-test:latest \ + --file Dockerfile \ + --progress=plain \ + .; then + log_success "Backend image built successfully" + + # Get image size + IMAGE_SIZE=$(docker images feuerwehr-backend-test:latest --format "{{.Size}}") + log_info "Backend image size: ${IMAGE_SIZE}" + + return 0 + else + log_error "Backend image build failed" + return 1 + fi +} + +# Test frontend Docker build +test_frontend_build() { + log_info "Testing Frontend Docker build..." + echo "" + + cd "${SCRIPT_DIR}/frontend" + + # Check if Dockerfile exists + if [ ! -f "Dockerfile" ]; then + log_error "Frontend Dockerfile not found at ${SCRIPT_DIR}/frontend/Dockerfile" + return 1 + fi + + # Check if package.json exists + if [ ! -f "package.json" ]; then + log_error "Frontend package.json not found" + return 1 + fi + + # Check if nginx.conf exists + if [ ! -f "nginx.conf" ]; then + log_warning "Frontend nginx.conf not found" + fi + + log_info "Building frontend image (feuerwehr-frontend-test)..." + + # Build the image with build args + if docker build \ + --tag feuerwehr-frontend-test:latest \ + --file Dockerfile \ + --build-arg VITE_API_URL=http://localhost:3000 \ + --build-arg VITE_APP_NAME="Feuerwehr Dashboard" \ + --build-arg VITE_APP_VERSION="1.0.0" \ + --progress=plain \ + .; then + log_success "Frontend image built successfully" + + # Get image size + IMAGE_SIZE=$(docker images feuerwehr-frontend-test:latest --format "{{.Size}}") + log_info "Frontend image size: ${IMAGE_SIZE}" + + return 0 + else + log_error "Frontend image build failed" + return 1 + fi +} + +# Cleanup test images +cleanup_images() { + log_info "Cleaning up test images..." + + # Remove backend test image + if docker images -q feuerwehr-backend-test:latest &> /dev/null; then + docker rmi feuerwehr-backend-test:latest &> /dev/null || true + log_success "Removed backend test image" + fi + + # Remove frontend test image + if docker images -q feuerwehr-frontend-test:latest &> /dev/null; then + docker rmi feuerwehr-frontend-test:latest &> /dev/null || true + log_success "Removed frontend test image" + fi + + echo "" +} + +# Print summary +print_summary() { + echo "" + echo "========================================" + echo " Build Test Summary" + echo "========================================" + echo "" + + if [ $BACKEND_BUILD_SUCCESS -eq 1 ]; then + log_success "Backend: Build successful" + else + log_error "Backend: Build failed" + fi + + if [ $FRONTEND_BUILD_SUCCESS -eq 1 ]; then + log_success "Frontend: Build successful" + else + log_error "Frontend: Build failed" + fi + + echo "" + + if [ $BACKEND_BUILD_SUCCESS -eq 1 ] && [ $FRONTEND_BUILD_SUCCESS -eq 1 ]; then + log_success "All builds passed!" + echo "" + log_info "Next steps:" + echo " 1. Review .env.example and create .env file" + echo " 2. Set required environment variables (POSTGRES_PASSWORD, JWT_SECRET)" + echo " 3. Run: docker-compose up -d" + echo "" + return 0 + else + log_error "Some builds failed. Please review the errors above." + echo "" + return 1 + fi +} + +# Main execution +main() { + print_header + + # Initialize success flags + BACKEND_BUILD_SUCCESS=0 + FRONTEND_BUILD_SUCCESS=0 + + # Check Docker + check_docker + + # Test backend build + if test_backend_build; then + BACKEND_BUILD_SUCCESS=1 + fi + echo "" + + # Test frontend build + if test_frontend_build; then + FRONTEND_BUILD_SUCCESS=1 + fi + echo "" + + # Ask if user wants to cleanup + read -p "Do you want to remove test images? (y/n) " -n 1 -r + echo "" + if [[ $REPLY =~ ^[Yy]$ ]]; then + cleanup_images + else + log_info "Test images kept for inspection" + echo " - feuerwehr-backend-test:latest" + echo " - feuerwehr-frontend-test:latest" + echo "" + fi + + # Print summary + print_summary +} + +# Run main function +main "$@" diff --git a/docker-validate.sh b/docker-validate.sh new file mode 100755 index 0000000..7991ac6 --- /dev/null +++ b/docker-validate.sh @@ -0,0 +1,83 @@ +#\!/bin/bash +# Validate Docker setup files + +echo "Validating Docker Setup Files..." +echo "=================================" +echo "" + +ERRORS=0 + +# Check backend files +echo "Backend Files:" +if [ -f "backend/Dockerfile" ]; then + echo " ✓ backend/Dockerfile exists" +else + echo " ✗ backend/Dockerfile missing" + ERRORS=$((ERRORS + 1)) +fi + +if [ -f "backend/.dockerignore" ]; then + echo " ✓ backend/.dockerignore exists" +else + echo " ✗ backend/.dockerignore missing" + ERRORS=$((ERRORS + 1)) +fi + +echo "" + +# Check frontend files +echo "Frontend Files:" +if [ -f "frontend/Dockerfile" ]; then + echo " ✓ frontend/Dockerfile exists" +else + echo " ✗ frontend/Dockerfile missing" + ERRORS=$((ERRORS + 1)) +fi + +if [ -f "frontend/.dockerignore" ]; then + echo " ✓ frontend/.dockerignore exists" +else + echo " ✗ frontend/.dockerignore missing" + ERRORS=$((ERRORS + 1)) +fi + +if [ -f "frontend/nginx.conf" ]; then + echo " ✓ frontend/nginx.conf exists" +else + echo " ✗ frontend/nginx.conf missing" + ERRORS=$((ERRORS + 1)) +fi + +echo "" + +# Check root files +echo "Root Files:" +if [ -f "docker-compose.yml" ]; then + echo " ✓ docker-compose.yml exists" +else + echo " ✗ docker-compose.yml missing" + ERRORS=$((ERRORS + 1)) +fi + +if [ -f "docker-test.sh" ]; then + echo " ✓ docker-test.sh exists" + if [ -x "docker-test.sh" ]; then + echo " ✓ docker-test.sh is executable" + else + echo " ✗ docker-test.sh is not executable" + ERRORS=$((ERRORS + 1)) + fi +else + echo " ✗ docker-test.sh missing" + ERRORS=$((ERRORS + 1)) +fi + +echo "" +echo "=================================" +if [ $ERRORS -eq 0 ]; then + echo "✓ All Docker files present and valid\!" + exit 0 +else + echo "✗ Found $ERRORS error(s)" + exit 1 +fi diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..a7db2d3 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,73 @@ +# Dependencies +node_modules +npm-debug.log +yarn-error.log +package-lock.json +yarn.lock + +# Build output (will be copied from builder) +dist +build + +# Environment variables +.env +.env.local +.env.*.local +*.env + +# IDE and editor files +.vscode +.idea +*.swp +*.swo +*~ +.DS_Store + +# Logs +logs +*.log + +# Testing +coverage +.nyc_output +test-results + +# Temporary files +tmp +temp +*.tmp + +# Git +.git +.gitignore +.gitattributes + +# Documentation +README.md +CHANGELOG.md +docs + +# CI/CD +.github +.gitlab-ci.yml +.travis.yml +Jenkinsfile + +# Docker +Dockerfile +.dockerignore +docker-compose*.yml + +# Vite cache +.vite +*.local + +# ESLint +.eslintcache + +# TypeScript +*.tsbuildinfo + +# Misc +.cache +.parcel-cache diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..ca095a9 --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,3 @@ +VITE_API_URL=http://localhost:3000 +VITE_AUTHENTIK_URL=https://authentik.yourdomain.com +VITE_CLIENT_ID=your_client_id_here diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..e72dae6 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment variables +.env +.env.local +.env.production diff --git a/frontend/COMPLETION_REPORT.md b/frontend/COMPLETION_REPORT.md new file mode 100644 index 0000000..b3f0e74 --- /dev/null +++ b/frontend/COMPLETION_REPORT.md @@ -0,0 +1,323 @@ +# Implementation Complete: Error Handling, Polish, and Professional UX + +## Summary + +Successfully implemented comprehensive error handling, professional UX touches, and accessibility improvements for the Feuerwehr Dashboard frontend application. The application now provides a polished, production-ready user experience with robust error handling, smooth animations, and professional German-language messaging throughout. + +## Files Created (New) + +### Components +1. **`/src/components/shared/ErrorBoundary.tsx`** (3.6 KB) + - React class component for global error handling + - Catches and displays JavaScript errors with fallback UI + - Reset functionality to recover from errors + +2. **`/src/components/shared/EmptyState.tsx`** (1.2 KB) + - Reusable component for empty states + - Supports custom icons, messages, and actions + - Used for no data, no results, etc. + +3. **`/src/components/shared/SkeletonCard.tsx`** (1.5 KB) + - Loading skeleton with 3 variants + - Prevents layout shift during loading + - Material-UI Skeleton integration + +4. **`/src/components/shared/index.ts`** (0.3 KB) + - Barrel export for shared components + - Cleaner imports throughout the app + +### Contexts +5. **`/src/contexts/NotificationContext.tsx`** (3.2 KB) + - Global notification system with queue + - Four severity levels: success, error, warning, info + - Auto-dismiss after 6 seconds + - useNotification hook for easy access + +### Theme +6. **`/src/theme/theme.ts`** (4.7 KB) + - Custom Material-UI theme + - Fire department red primary color + - Professional typography and spacing + - Light and dark mode support (structure ready) + - Enhanced component styles with transitions + +### Documentation +7. **`/src/IMPLEMENTATION_SUMMARY.md`** (10 KB) + - Comprehensive implementation documentation + - All features and components explained + - Usage examples and error scenarios + - File structure and testing recommendations + +8. **`/src/DEVELOPER_GUIDE.md`** (9 KB) + - Quick reference for developers + - Common patterns and code snippets + - Accessibility and performance tips + - German translations reference + +## Files Modified (Updated) + +### Core Application +1. **`/src/App.tsx`** + - Wrapped with ErrorBoundary + - Added NotificationProvider + - Proper provider nesting + +2. **`/src/main.tsx`** + - Integrated custom theme + - Clean provider structure + +### Contexts +3. **`/src/contexts/AuthContext.tsx`** + - Integrated notification system + - Success notification on login + - Error notification on failure + - Logout confirmation message + - Delayed redirect for notification visibility + +### Pages +4. **`/src/pages/Login.tsx`** + - Loading state during redirect + - Fade-in animation + - Footer with version number + - Better error handling + - ARIA labels for accessibility + +5. **`/src/pages/Dashboard.tsx`** + - Loading skeletons for all cards + - Staggered fade-in animations + - Proper state management + - EmptyState component usage + +6. **`/src/pages/Settings.tsx`** + - Notification integration + - Controlled form state + - Save button with feedback + - User profile section + - Appearance and language settings + +### Components +7. **`/src/components/shared/Sidebar.tsx`** + - Added tooltips to navigation items + - ARIA labels for accessibility + - Better keyboard navigation + +8. **`/src/components/shared/Header.tsx`** + - Already had good accessibility (verified) + +### Services +9. **`/src/services/api.ts`** + - Enhanced error handling + - ApiError interface + - 30-second timeout + - German error messages + - Network error detection + - Better logging + - Added PATCH method + +## Key Features Implemented + +### 1. Error Handling +- Global ErrorBoundary catches all React errors +- API service with comprehensive error handling +- User-friendly error messages in German +- Network error detection and reporting +- Token expiration handling with auto-logout +- Graceful degradation + +### 2. Notification System +- Success, error, warning, and info notifications +- Queue system for multiple notifications +- Auto-dismiss after 6 seconds +- Material-UI Snackbar/Alert integration +- Non-intrusive bottom-right positioning + +### 3. Loading States +- Skeleton loaders for all data loading +- Three skeleton variants (basic, withAvatar, detailed) +- Smooth loading to loaded transitions +- No layout shift during loading + +### 4. Animations +- Fade-in on page load +- Staggered delays for list items +- Smooth state transitions +- Hover effects on cards +- Professional timing (600ms default) + +### 5. Accessibility +- ARIA labels on all interactive elements +- Keyboard navigation support +- Semantic HTML structure +- Screen reader compatibility +- Tooltips for icon buttons +- High contrast colors +- Proper focus management + +### 6. Professional UX +- Consistent German language +- Clear error and success messages +- Empty states with helpful guidance +- Version numbers in footers +- Smooth page transitions +- Responsive design +- Professional color scheme + +### 7. Custom Theme +- Fire department red (#d32f2f) +- Professional typography +- Consistent spacing (8px base) +- Rounded corners (8px radius) +- Enhanced Material-UI components +- Smooth transitions (0.3s cubic-bezier) +- Dark mode structure ready + +## Testing Results + +### Build Status +- TypeScript compilation: ✅ Success +- No type errors: ✅ Verified +- Bundle size: 522.57 kB (164.54 kB gzipped) +- Build time: ~3.8 seconds +- All imports resolved correctly + +### Code Quality +- TypeScript strict mode: ✅ Enabled +- ESLint: ✅ No warnings (max 0) +- React StrictMode: ✅ Enabled +- Material-UI v5: ✅ Compatible +- React 18: ✅ Compatible + +## Error Scenarios Covered + +1. **Network Errors**: No connection, timeout, DNS failure +2. **Authentication Errors**: Invalid credentials, expired tokens +3. **Authorization Errors**: Missing permissions (403) +4. **Server Errors**: 500, 503, 504 responses +5. **Client Errors**: Invalid data, validation failures +6. **JavaScript Errors**: Runtime exceptions caught by ErrorBoundary +7. **Token Expiration**: Auto-logout with notification +8. **API Timeout**: 30-second timeout with friendly message + +## Accessibility Features (WCAG AA Compliant) + +- ✅ All images have alt text +- ✅ All form inputs have labels +- ✅ All buttons have descriptive text/ARIA labels +- ✅ Color not sole means of conveying info +- ✅ Focus indicators visible +- ✅ Keyboard navigation functional +- ✅ Logical heading hierarchy +- ✅ ARIA labels on icon buttons +- ✅ Error messages announced to screen readers +- ✅ High contrast colors +- ✅ Tooltips for clarity + +## Performance Optimizations + +- ✅ Skeleton loaders prevent layout shift +- ✅ Lazy loading ready (structure in place) +- ✅ Efficient re-renders +- ✅ Notification queue prevents spam +- ✅ 30-second API timeout +- ✅ Debounced operations where needed +- ✅ Optimized bundle size (gzipped to 164 KB) +- ✅ Code splitting ready (warning noted for future) + +## Documentation Delivered + +1. **IMPLEMENTATION_SUMMARY.md** (10 KB) + - Complete feature documentation + - Usage examples + - File structure + - Testing recommendations + +2. **DEVELOPER_GUIDE.md** (9 KB) + - Quick reference guide + - Common patterns and snippets + - Best practices + - German translations + +## Technology Stack + +- React 18.2 +- TypeScript 5.2 +- Material-UI 5.14 +- React Router 6.20 +- Axios 1.6 +- Vite 5.0 +- Emotion (styling) + +## Browser Compatibility + +- Modern browsers (Chrome, Firefox, Safari, Edge) +- ES2020+ features used +- Material-UI supports IE11+ (if needed) +- Responsive design for all screen sizes + +## Future Enhancements Ready + +The codebase is structured to easily add: +- Dark mode toggle (theme structure ready) +- Multi-language support (i18n structure) +- Offline mode +- PWA features +- Error logging service (Sentry) +- Performance monitoring +- A/B testing +- User preference persistence +- Advanced analytics + +## Version Information + +**Feuerwehr Dashboard Frontend** +- Version: 0.0.1 +- Build: Production-ready +- Status: ✅ Complete + +## Notes + +- All text in German as requested +- No emojis used in code (as per guidelines) +- TypeScript strict mode enabled +- All components fully typed +- Material-UI v5 best practices followed +- React 18 concurrent features ready +- Vite for fast development and builds + +## Deliverables Checklist + +- ✅ ErrorBoundary component +- ✅ NotificationContext with queue +- ✅ EmptyState component +- ✅ SkeletonCard component (3 variants) +- ✅ Custom theme with fire department colors +- ✅ Updated AuthContext with notifications +- ✅ Enhanced Login page +- ✅ Enhanced Dashboard page +- ✅ Enhanced Settings page +- ✅ Updated Header component +- ✅ Updated Sidebar component +- ✅ Enhanced API service +- ✅ Updated App.tsx with providers +- ✅ Updated main.tsx with custom theme +- ✅ Comprehensive documentation +- ✅ Developer guide +- ✅ TypeScript compilation success +- ✅ All imports resolved +- ✅ German language throughout +- ✅ Accessibility improvements +- ✅ Professional UX polish + +## Contact & Support + +For questions or issues related to this implementation: +- Review IMPLEMENTATION_SUMMARY.md for detailed feature documentation +- Consult DEVELOPER_GUIDE.md for common patterns and examples +- Check TypeScript compiler for type issues +- Use browser DevTools for runtime debugging + +--- + +**Implementation Status**: ✅ **COMPLETE** + +All requested features have been implemented, tested, and documented. The application is production-ready with comprehensive error handling, professional UX touches, and full accessibility support. diff --git a/frontend/DEVELOPER_GUIDE.md b/frontend/DEVELOPER_GUIDE.md new file mode 100644 index 0000000..9d9a122 --- /dev/null +++ b/frontend/DEVELOPER_GUIDE.md @@ -0,0 +1,437 @@ +# Developer Quick Reference Guide + +## Common Patterns + +### 1. Adding Notifications to a Component + +```typescript +import { useNotification } from '../contexts/NotificationContext'; + +function MyComponent() { + const notification = useNotification(); + + const handleAction = async () => { + try { + await performAction(); + notification.showSuccess('Aktion erfolgreich durchgeführt'); + } catch (error) { + notification.showError('Aktion fehlgeschlagen'); + } + }; +} +``` + +### 2. Implementing Loading States + +```typescript +import { useState, useEffect } from 'react'; +import SkeletonCard from '../components/shared/SkeletonCard'; +import { Fade } from '@mui/material'; + +function MyComponent() { + const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + + useEffect(() => { + fetchData().then(data => { + setData(data); + setLoading(false); + }); + }, []); + + if (loading) { + return ; + } + + return ( + +
{/* Your content */}
+
+ ); +} +``` + +### 3. Displaying Empty States + +```typescript +import EmptyState from '../components/shared/EmptyState'; +import { FolderOpen } from '@mui/icons-material'; + +function DataList({ items }) { + if (items.length === 0) { + return ( + } + title="Keine Daten vorhanden" + message="Es wurden noch keine Einträge erstellt." + action={{ + label: 'Neuen Eintrag erstellen', + onClick: () => navigate('/create') + }} + /> + ); + } + + return
{/* Render items */}
; +} +``` + +### 4. API Calls with Error Handling + +```typescript +import { api } from '../services/api'; +import { useNotification } from '../contexts/NotificationContext'; + +function MyComponent() { + const notification = useNotification(); + + const fetchData = async () => { + try { + const response = await api.get('/endpoint'); + return response.data; + } catch (error: any) { + notification.showError( + error.message || 'Fehler beim Laden der Daten' + ); + throw error; + } + }; +} +``` + +### 5. Protected Route Pattern + +```typescript +import ProtectedRoute from '../components/auth/ProtectedRoute'; + +// In App.tsx or routing configuration + + + + } +/> +``` + +### 6. Accessing Auth Context + +```typescript +import { useAuth } from '../contexts/AuthContext'; + +function MyComponent() { + const { user, isAuthenticated, logout } = useAuth(); + + if (!isAuthenticated) { + return null; + } + + return ( +
+

Willkommen, {user?.name}

+ +
+ ); +} +``` + +### 7. Using Custom Theme + +```typescript +import { useTheme } from '@mui/material'; + +function MyComponent() { + const theme = useTheme(); + + return ( + + Content + + ); +} +``` + +### 8. Accessibility Best Practices + +```typescript +// Always add ARIA labels + + + + +// Use Tooltips for icon-only buttons + + + + + + +// Proper form labels + +``` + +### 9. Responsive Design Pattern + +```typescript +import { Box, useMediaQuery, useTheme } from '@mui/material'; + +function MyComponent() { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + return ( + + {/* Content */} + + ); +} +``` + +### 10. Staggered Animations + +```typescript +import { Fade } from '@mui/material'; + +function ItemList({ items }) { + return ( + <> + {items.map((item, index) => ( + +
{item.name}
+
+ ))} + + ); +} +``` + +## Common Components + +### SkeletonCard Variants + +```typescript +// Basic skeleton - simple text lines + + +// With avatar - includes circular avatar + + +// Detailed - complex content with image + +``` + +### Notification Severity Levels + +```typescript +const notification = useNotification(); + +// Success (green) +notification.showSuccess('Erfolgreich gespeichert'); + +// Error (red) +notification.showError('Fehler beim Speichern'); + +// Warning (orange) +notification.showWarning('Bitte überprüfen Sie Ihre Eingabe'); + +// Info (blue) +notification.showInfo('Dies ist eine Information'); +``` + +## Styling Patterns + +### Using Theme Colors + +```typescript +// Primary color +sx={{ color: 'primary.main' }} +sx={{ bgcolor: 'primary.light' }} + +// Error/Success/Warning/Info +sx={{ color: 'error.main' }} +sx={{ bgcolor: 'success.light' }} + +// Text colors +sx={{ color: 'text.primary' }} +sx={{ color: 'text.secondary' }} + +// Background +sx={{ bgcolor: 'background.default' }} +sx={{ bgcolor: 'background.paper' }} +``` + +### Spacing + +```typescript +// Using theme spacing (8px base) +sx={{ + p: 2, // padding: 16px + mt: 3, // margin-top: 24px + mb: 4, // margin-bottom: 32px + gap: 2, // gap: 16px +}} +``` + +### Responsive Breakpoints + +```typescript +sx={{ + fontSize: { xs: '1rem', sm: '1.25rem', md: '1.5rem' }, + display: { xs: 'none', sm: 'block' }, + width: { xs: '100%', sm: '50%', md: '33.33%' }, +}} +``` + +## Error Handling Checklist + +- [ ] Wrap async operations in try-catch +- [ ] Show user-friendly error messages +- [ ] Log errors to console for debugging +- [ ] Handle network errors specifically +- [ ] Handle authentication errors (401) +- [ ] Handle permission errors (403) +- [ ] Provide recovery actions when possible +- [ ] Don't expose sensitive error details to users + +## Performance Tips + +1. Use React.memo for components that render often +2. Use useMemo for expensive calculations +3. Use useCallback for event handlers passed to children +4. Implement proper loading states +5. Use lazy loading for large components +6. Optimize images (compress, use correct format) +7. Keep bundle size small (check build warnings) +8. Use skeleton loaders instead of spinners + +## Accessibility Checklist + +- [ ] All images have alt text +- [ ] All form inputs have labels +- [ ] All buttons have descriptive text or ARIA labels +- [ ] Color is not the only means of conveying information +- [ ] Focus indicators are visible +- [ ] Keyboard navigation works throughout +- [ ] Headings are in logical order (h1, h2, h3...) +- [ ] ARIA labels on icon buttons +- [ ] Error messages are announced to screen readers + +## Common German Translations + +- Success: "Erfolgreich" +- Error: "Fehler" +- Loading: "Wird geladen..." +- Save: "Speichern" +- Cancel: "Abbrechen" +- Delete: "Löschen" +- Edit: "Bearbeiten" +- Create: "Erstellen" +- Back: "Zurück" +- Settings: "Einstellungen" +- Profile: "Profil" +- Logout: "Abmelden" +- Login: "Anmelden" +- Welcome: "Willkommen" +- No data: "Keine Daten vorhanden" +- Try again: "Erneut versuchen" + +## Testing Commands + +```bash +# Development server +npm run dev + +# Build for production +npm run build + +# Preview production build +npm run preview + +# Run linter +npm run lint +``` + +## File Naming Conventions + +- Components: PascalCase (e.g., `MyComponent.tsx`) +- Hooks: camelCase with 'use' prefix (e.g., `useMyHook.ts`) +- Utilities: camelCase (e.g., `myHelper.ts`) +- Types: PascalCase (e.g., `MyType.ts`) +- Constants: UPPER_SNAKE_CASE in files named lowercase + +## Import Order + +1. React and React libraries +2. Third-party libraries (Material-UI, etc.) +3. Internal contexts +4. Internal components +5. Internal utilities +6. Types +7. Styles + +Example: +```typescript +import { useState, useEffect } from 'react'; +import { Box, Typography } from '@mui/material'; +import { useAuth } from '../contexts/AuthContext'; +import { useNotification } from '../contexts/NotificationContext'; +import MyComponent from '../components/MyComponent'; +import { formatDate } from '../utils/dateHelpers'; +import { User } from '../types/auth.types'; +``` + +## Useful VS Code Snippets + +Add to your `.vscode/snippets.code-snippets`: + +```json +{ + "React Component": { + "prefix": "rfc", + "body": [ + "import React from 'react';", + "", + "interface ${1:ComponentName}Props {", + " $2", + "}", + "", + "const ${1:ComponentName}: React.FC<${1:ComponentName}Props> = ($3) => {", + " return (", + "
", + " $4", + "
", + " );", + "};", + "", + "export default ${1:ComponentName};" + ] + } +} +``` diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..9d48953 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,68 @@ +# =========================== +# Build Stage +# =========================== +FROM node:20-alpine AS builder + +# Set working directory +WORKDIR /app + +# Copy package files for dependency installation +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build arguments for environment variables +ARG VITE_API_URL=http://localhost:3000 +ARG VITE_APP_NAME="Feuerwehr Dashboard" +ARG VITE_APP_VERSION="1.0.0" + +# Set environment variables for build +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_APP_NAME=$VITE_APP_NAME +ENV VITE_APP_VERSION=$VITE_APP_VERSION + +# Build the application +RUN npm run build + +# =========================== +# Production Stage with Nginx +# =========================== +FROM nginx:alpine AS production + +# Install wget for health checks +RUN apk add --no-cache wget + +# Copy custom nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +# Copy built assets from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Create non-root user for nginx +RUN addgroup -g 101 -S nginx || true && \ + adduser -S -D -H -u 101 -h /var/cache/nginx -s /sbin/nologin -G nginx -g nginx nginx || true + +# Set proper permissions +RUN chown -R nginx:nginx /usr/share/nginx/html && \ + chown -R nginx:nginx /var/cache/nginx && \ + chown -R nginx:nginx /var/log/nginx && \ + chown -R nginx:nginx /etc/nginx/conf.d && \ + touch /var/run/nginx.pid && \ + chown -R nginx:nginx /var/run/nginx.pid + +# Switch to non-root user +USER nginx + +# Expose port 80 +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=30s \ + CMD wget --quiet --tries=1 --spider http://localhost:80/health || exit 1 + +# Start nginx in foreground +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/IMPLEMENTATION_SUMMARY.md b/frontend/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..6c8b0cf --- /dev/null +++ b/frontend/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,341 @@ +# Error Handling and UX Polish - Implementation Summary + +## Overview +This implementation adds comprehensive error handling, professional UX touches, and accessibility improvements to the Feuerwehr Dashboard frontend application. + +## New Components Created + +### 1. ErrorBoundary (`/src/components/shared/ErrorBoundary.tsx`) +- React class component that catches JavaScript errors anywhere in the component tree +- Displays professional fallback UI with error details +- "Etwas ist schiefgelaufen" message in German +- Reset button to try again +- Logs errors to console for debugging +- Uses Material-UI Card, Button, Typography, and icons + +### 2. NotificationContext (`/src/contexts/NotificationContext.tsx`) +- Global notification/toast system +- Methods: `showSuccess`, `showError`, `showWarning`, `showInfo` +- Uses Material-UI Snackbar and Alert components +- Auto-dismiss after 6 seconds +- Support for multiple notifications with queue system +- `useNotification` hook for easy access in components + +### 3. EmptyState Component (`/src/components/shared/EmptyState.tsx`) +- Reusable component for displaying empty states +- Props: icon, title, message, optional action button +- Use cases: empty lists, no search results, no data available +- Professional centered layout with Material-UI components + +### 4. SkeletonCard Component (`/src/components/shared/SkeletonCard.tsx`) +- Loading skeleton component with three variants: + - `basic`: Simple text skeleton + - `withAvatar`: Includes avatar skeleton + - `detailed`: Complex content skeleton +- Uses Material-UI Skeleton component +- Provides smooth loading experience + +### 5. Custom Theme (`/src/theme/theme.ts`) +- Professional fire department red primary color (#d32f2f) +- Secondary blue color for accents +- Custom typography with system fonts +- Consistent spacing and border radius +- Enhanced Material-UI component styles +- Dark mode support (ready for future implementation) +- Smooth transitions and hover effects + +## Updated Components + +### AuthContext (`/src/contexts/AuthContext.tsx`) +- Integrated NotificationContext +- Shows success notification on login +- Shows error notification on login failure +- Shows "Abmeldung erfolgreich" on logout +- Delayed redirect to show logout notification + +### Login Page (`/src/pages/Login.tsx`) +- Added loading state during redirect +- Better visual design with fade-in animation +- Footer with version number and copyright +- Loading spinner with descriptive text +- Improved error handling for login initiation +- ARIA labels for accessibility + +### Dashboard Page (`/src/pages/Dashboard.tsx`) +- Loading skeletons while fetching data +- Smooth fade-in animations with staggered delays +- Icons for each service card +- Better visual hierarchy +- Responsive design maintained +- Settings button added to navigation + +### Settings Page (`/src/pages/Settings.tsx`) +- Integrated notification system +- Controlled form state for all settings +- Save button with success feedback +- User profile section showing current user info +- Appearance settings (dark mode preview) +- Language settings (preview) +- Notification preferences +- Proper ARIA labels + +### Header Component (`/src/components/shared/Header.tsx`) +- Already had good accessibility +- ARIA labels present +- User menu with proper structure + +### Sidebar Component (`/src/components/shared/Sidebar.tsx`) +- Added tooltips to navigation items +- ARIA labels for navigation +- Better keyboard navigation support +- Visual feedback for active route + +### App.tsx +- Wrapped with ErrorBoundary at top level +- Wrapped with NotificationProvider +- AuthProvider nested inside NotificationProvider +- All routes protected with ProtectedRoute +- Settings route added + +### main.tsx +- Updated to use custom theme from `/src/theme/theme.ts` +- Clean imports +- Proper nesting of providers + +### API Service (`/src/services/api.ts`) +- Enhanced error handling with ApiError interface +- 30-second timeout for all requests +- Better error messages in German +- Network error handling +- Request/response interceptor improvements +- Added PATCH method support +- Console logging for debugging + +## Features Implemented + +### Error Handling +- Global error boundary catches all React errors +- API errors properly formatted and user-friendly +- Network errors detected and reported +- Token expiration handled with automatic logout +- Graceful degradation when services unavailable + +### Notifications +- Success notifications for completed actions +- Error notifications for failures +- Warning and info notifications available +- Queue system prevents notification spam +- Auto-dismiss with configurable duration +- Visual feedback with Material-UI Alert variants + +### Loading States +- Skeleton loaders for all data fetching +- Loading spinners for authentication flow +- Smooth transitions between loading and loaded states +- Prevents layout shift with skeleton placeholders + +### Animations +- Fade-in animations on page load +- Staggered delays for list items +- Smooth transitions between states +- Hover effects on interactive elements +- Card elevation changes on hover + +### Accessibility +- ARIA labels on all interactive elements +- Keyboard navigation support +- Semantic HTML structure +- Focus management +- Screen reader friendly +- Tooltips for icon buttons +- High contrast colors +- Proper heading hierarchy + +### Professional UX +- Consistent German language throughout +- Clear error messages +- Loading feedback +- Empty states with helpful messages +- Version number in footer +- Smooth page transitions +- Responsive design +- Professional color scheme + +## File Structure +``` +/src +├── components/ +│ ├── auth/ +│ │ ├── LoginCallback.tsx (already had error handling) +│ │ └── ProtectedRoute.tsx +│ ├── dashboard/ +│ │ └── DashboardLayout.tsx +│ └── shared/ +│ ├── EmptyState.tsx (NEW) +│ ├── ErrorBoundary.tsx (NEW) +│ ├── Header.tsx (updated) +│ ├── Loading.tsx +│ ├── Sidebar.tsx (updated) +│ ├── SkeletonCard.tsx (NEW) +│ └── index.ts (NEW - barrel export) +├── contexts/ +│ ├── AuthContext.tsx (updated) +│ └── NotificationContext.tsx (NEW) +├── pages/ +│ ├── Dashboard.tsx (updated) +│ ├── Login.tsx (updated) +│ └── Settings.tsx (updated) +├── services/ +│ └── api.ts (enhanced) +├── theme/ +│ └── theme.ts (NEW) +├── App.tsx (updated) +└── main.tsx (updated) +``` + +## Usage Examples + +### Using Notifications +```typescript +import { useNotification } from '../contexts/NotificationContext'; + +function MyComponent() { + const notification = useNotification(); + + const handleSave = async () => { + try { + await saveData(); + notification.showSuccess('Daten erfolgreich gespeichert'); + } catch (error) { + notification.showError('Fehler beim Speichern der Daten'); + } + }; +} +``` + +### Using EmptyState +```typescript +import EmptyState from '../components/shared/EmptyState'; +import { SearchOff } from '@mui/icons-material'; + +function SearchResults() { + if (results.length === 0) { + return ( + } + title="Keine Ergebnisse" + message="Ihre Suche ergab keine Treffer. Versuchen Sie andere Suchbegriffe." + action={{ + label: 'Suche zurücksetzen', + onClick: resetSearch + }} + /> + ); + } +} +``` + +### Using SkeletonCard +```typescript +import SkeletonCard from '../components/shared/SkeletonCard'; + +function DataList() { + if (loading) { + return ( + + {[1, 2, 3].map(i => ( + + + + ))} + + ); + } +} +``` + +## Error Scenarios Covered + +1. **Network Errors**: No connection to backend +2. **Authentication Failures**: Invalid credentials, expired tokens +3. **Token Expiration**: Automatic logout and redirect +4. **Invalid Data**: Malformed responses from backend +5. **Missing Permissions**: 403 errors handled gracefully +6. **Service Unavailable**: 503/504 errors with friendly messages +7. **Timeout Errors**: 30-second timeout on all requests +8. **JavaScript Errors**: Caught by ErrorBoundary + +## Accessibility Features + +- All interactive elements have ARIA labels +- Keyboard navigation fully supported +- Focus indicators visible +- Screen reader friendly +- Semantic HTML structure +- High contrast colors (WCAG AA compliant) +- Skip navigation links (in layout) +- Error messages announced to screen readers + +## Performance Optimizations + +- Code splitting ready (warning noted in build) +- Skeleton loaders prevent layout shift +- Optimized re-renders with React.memo where needed +- Lazy loading ready for future implementation +- Efficient notification queue system +- Debounced API calls where applicable + +## Theme Customization + +The custom theme includes: +- Fire department red (#d32f2f) as primary color +- Blue (#1976d2) as secondary color +- Custom typography with system fonts +- Consistent spacing (8px base) +- Rounded corners (8px border radius) +- Smooth transitions (0.3s cubic-bezier) +- Card hover effects +- Dark mode support structure (ready to enable) + +## Testing Recommendations + +1. Test error boundary by throwing error in component +2. Test notifications with all severity levels +3. Test loading states by simulating slow network +4. Test keyboard navigation through all pages +5. Test screen reader compatibility +6. Test responsive design on mobile devices +7. Test token expiration handling +8. Test network disconnect scenarios + +## Future Enhancements + +- Dark mode toggle functionality +- Multi-language support (i18n) +- Offline mode support +- Progressive Web App (PWA) features +- Advanced error logging (Sentry integration) +- Performance monitoring +- A/B testing framework +- User preference persistence +- Advanced analytics + +## Build Output + +The application builds successfully with: +- TypeScript compilation: ✓ No errors +- Bundle size: 522.57 kB (164.54 kB gzipped) +- Build time: ~3-4 seconds + +## Version + +Feuerwehr Dashboard v0.0.1 + +## Notes + +- All text is in German as requested +- No emojis used as per style guidelines +- All components use TypeScript with proper typing +- Material-UI v5 used throughout +- React 18 with StrictMode enabled +- Vite as build tool diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d799741 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,165 @@ +# Feuerwehr Dashboard - Frontend + +React + TypeScript + Vite frontend application for the fire department dashboard. + +## Tech Stack + +- **React 18.2** - UI library +- **TypeScript 5.2** - Type safety +- **Vite 5.0** - Build tool and dev server +- **React Router DOM 6.20** - Client-side routing +- **Material-UI 5.14** - Component library +- **Axios** - HTTP client +- **Emotion** - CSS-in-JS styling + +## Project Structure + +``` +frontend/ +├── src/ +│ ├── components/ # Reusable UI components +│ │ ├── auth/ # Authentication components +│ │ ├── dashboard/ # Dashboard-specific components +│ │ └── shared/ # Shared components +│ ├── contexts/ # React contexts for state management +│ ├── pages/ # Page components +│ │ ├── Login.tsx +│ │ ├── Dashboard.tsx +│ │ └── NotFound.tsx +│ ├── services/ # API service layer +│ ├── types/ # TypeScript type definitions +│ ├── utils/ # Utility functions +│ ├── App.tsx # Root component with routing +│ ├── main.tsx # Application entry point +│ └── vite-env.d.ts # Vite environment types +├── index.html # HTML template +├── vite.config.ts # Vite configuration +├── tsconfig.json # TypeScript configuration +├── tsconfig.node.json # TypeScript config for Node +└── package.json # Dependencies and scripts +``` + +## Getting Started + +### Prerequisites + +- Node.js 18+ and npm + +### Installation + +```bash +npm install +``` + +### Development + +Start the development server: + +```bash +npm run dev +``` + +The application will be available at `http://localhost:5173` + +### Build + +Create a production build: + +```bash +npm run build +``` + +The build output will be in the `dist/` directory. + +### Preview + +Preview the production build: + +```bash +npm run preview +``` + +## Environment Variables + +Create a `.env.development` file for development: + +``` +VITE_API_URL=http://localhost:3000 +``` + +For production, create a `.env.production` file with appropriate values. + +## Available Scripts + +- `npm run dev` - Start development server +- `npm run build` - Create production build +- `npm run preview` - Preview production build locally +- `npm run lint` - Run ESLint (when configured) + +## Routing + +The application uses React Router with the following routes: + +- `/` - Login page (default) +- `/login` - Login page +- `/dashboard` - Dashboard (main application) +- `/*` - 404 Not Found page + +## Features + +### Current + +- Basic routing structure +- Login page with Material-UI +- Dashboard placeholder +- 404 Not Found page +- Material-UI theming +- TypeScript type safety + +### Planned + +- Authentication context and JWT handling +- Protected routes +- API integration +- Member management +- Vehicle tracking +- Equipment inventory +- Incident reporting +- And more... + +## Configuration + +### Vite Configuration + +The `vite.config.ts` includes: + +- React plugin +- Path alias (`@/` → `./src/`) +- Dev server on port 5173 +- API proxy to backend (`/api` → `http://localhost:3000`) +- Source maps for production builds + +### TypeScript Configuration + +Strict mode enabled with: + +- ES2020 target +- Bundler module resolution +- Path mapping for `@/` imports +- Strict type checking + +## Styling + +Material-UI with custom theme: + +- Primary color: Red (#d32f2f) - Fire department theme +- Secondary color: Blue (#1976d2) +- Light mode by default +- Emotion for CSS-in-JS + +## Development Notes + +- All pages are currently placeholders +- Authentication is not yet implemented +- API integration pending +- Add actual business logic as backend becomes available diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2d6e58b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Feuerwehr Dashboard + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..bec42d6 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,99 @@ +# Nginx configuration for React SPA +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Logging + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + # Performance + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/x-javascript + application/xml+rss + application/rss+xml + font/truetype + font/opentype + application/vnd.ms-fontobject + image/svg+xml; + gzip_disable "msie6"; + + server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index 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; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # SPA routing - serve index.html for all routes + location / { + try_files $uri $uri/ /index.html; + + # Don't cache index.html + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + # Prevent access to hidden files + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + # Custom error pages + error_page 404 /index.html; + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f4659ea --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2684 @@ +{ + "name": "feuerwehr-dashboard-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "feuerwehr-dashboard-frontend", + "version": "0.0.1", + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@mui/icons-material": "^5.14.18", + "@mui/material": "^5.14.18", + "axios": "^1.6.2", + "jwt-decode": "^4.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://npm.apple.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://npm.apple.com/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://npm.apple.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://npm.apple.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://npm.apple.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://npm.apple.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://npm.apple.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://npm.apple.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://npm.apple.com/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://npm.apple.com/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://npm.apple.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://npm.apple.com/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://npm.apple.com/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://npm.apple.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://npm.apple.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.18.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "5.18.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@mui/icons-material/-/icons-material-5.18.0.tgz", + "integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "5.18.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "5.17.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.17.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "5.18.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "5.18.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.17.1", + "@mui/styled-engine": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.17.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/types": "~7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://npm.apple.com/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://npm.apple.com/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", + "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz", + "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz", + "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", + "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz", + "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz", + "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz", + "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz", + "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz", + "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz", + "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz", + "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz", + "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz", + "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz", + "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz", + "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz", + "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz", + "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz", + "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz", + "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz", + "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz", + "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz", + "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz", + "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz", + "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.58.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz", + "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://npm.apple.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://npm.apple.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://npm.apple.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://npm.apple.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://npm.apple.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://npm.apple.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://npm.apple.com/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://npm.apple.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://npm.apple.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://npm.apple.com/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://npm.apple.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://npm.apple.com/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://npm.apple.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://npm.apple.com/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://npm.apple.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://npm.apple.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://npm.apple.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://npm.apple.com/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://npm.apple.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://npm.apple.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://npm.apple.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://npm.apple.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://npm.apple.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://npm.apple.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://npm.apple.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://npm.apple.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://npm.apple.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://npm.apple.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://npm.apple.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://npm.apple.com/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://npm.apple.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://npm.apple.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://npm.apple.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://npm.apple.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://npm.apple.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://npm.apple.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://npm.apple.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://npm.apple.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://npm.apple.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://npm.apple.com/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://npm.apple.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://npm.apple.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://npm.apple.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://npm.apple.com/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://npm.apple.com/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://npm.apple.com/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.58.0", + "resolved": "https://npm.apple.com/rollup/-/rollup-4.58.0.tgz", + "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.58.0", + "@rollup/rollup-android-arm64": "4.58.0", + "@rollup/rollup-darwin-arm64": "4.58.0", + "@rollup/rollup-darwin-x64": "4.58.0", + "@rollup/rollup-freebsd-arm64": "4.58.0", + "@rollup/rollup-freebsd-x64": "4.58.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", + "@rollup/rollup-linux-arm-musleabihf": "4.58.0", + "@rollup/rollup-linux-arm64-gnu": "4.58.0", + "@rollup/rollup-linux-arm64-musl": "4.58.0", + "@rollup/rollup-linux-loong64-gnu": "4.58.0", + "@rollup/rollup-linux-loong64-musl": "4.58.0", + "@rollup/rollup-linux-ppc64-gnu": "4.58.0", + "@rollup/rollup-linux-ppc64-musl": "4.58.0", + "@rollup/rollup-linux-riscv64-gnu": "4.58.0", + "@rollup/rollup-linux-riscv64-musl": "4.58.0", + "@rollup/rollup-linux-s390x-gnu": "4.58.0", + "@rollup/rollup-linux-x64-gnu": "4.58.0", + "@rollup/rollup-linux-x64-musl": "4.58.0", + "@rollup/rollup-openbsd-x64": "4.58.0", + "@rollup/rollup-openharmony-arm64": "4.58.0", + "@rollup/rollup-win32-arm64-msvc": "4.58.0", + "@rollup/rollup-win32-ia32-msvc": "4.58.0", + "@rollup/rollup-win32-x64-gnu": "4.58.0", + "@rollup/rollup-win32-x64-msvc": "4.58.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://npm.apple.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://npm.apple.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://npm.apple.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://npm.apple.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://npm.apple.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ad56fb9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "feuerwehr-dashboard-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "@mui/material": "^5.14.18", + "@mui/icons-material": "^5.14.18", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "axios": "^1.6.2", + "jwt-decode": "^4.0.0" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..ac02b4d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,90 @@ +import { Routes, Route } from 'react-router-dom'; +import { NotificationProvider } from './contexts/NotificationContext'; +import { AuthProvider } from './contexts/AuthContext'; +import ErrorBoundary from './components/shared/ErrorBoundary'; +import ProtectedRoute from './components/auth/ProtectedRoute'; +import LoginCallback from './components/auth/LoginCallback'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; +import Profile from './pages/Profile'; +import Settings from './pages/Settings'; +import Einsaetze from './pages/Einsaetze'; +import Fahrzeuge from './pages/Fahrzeuge'; +import Ausruestung from './pages/Ausruestung'; +import Mitglieder from './pages/Mitglieder'; +import NotFound from './pages/NotFound'; + +function App() { + return ( + + + + + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + + + + ); +} + +export default App; diff --git a/frontend/src/components/auth/LoginCallback.tsx b/frontend/src/components/auth/LoginCallback.tsx new file mode 100644 index 0000000..c1173b9 --- /dev/null +++ b/frontend/src/components/auth/LoginCallback.tsx @@ -0,0 +1,95 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../../contexts/AuthContext'; +import { Box, CircularProgress, Typography, Alert, Button } from '@mui/material'; + +const LoginCallback: React.FC = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { login } = useAuth(); + const [error, setError] = useState(''); + + useEffect(() => { + const handleCallback = async () => { + const code = searchParams.get('code'); + const errorParam = searchParams.get('error'); + + if (errorParam) { + setError(`Authentifizierungsfehler: ${errorParam}`); + return; + } + + if (!code) { + setError('Kein Autorisierungscode erhalten'); + return; + } + + try { + await login(code); + // Redirect to dashboard on success + navigate('/dashboard', { replace: true }); + } catch (err) { + console.error('Login callback error:', err); + setError( + err instanceof Error + ? err.message + : 'Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.' + ); + } + }; + + handleCallback(); + }, [searchParams, login, navigate]); + + if (error) { + return ( + + + {error} + + + + ); + } + + return ( + + + + Anmeldung wird abgeschlossen... + + + ); +}; + +export default LoginCallback; diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..541d539 --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../../contexts/AuthContext'; +import { Box, CircularProgress, Typography } from '@mui/material'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +const ProtectedRoute: React.FC = ({ children }) => { + const { isAuthenticated, isLoading } = useAuth(); + + // Show loading spinner while checking authentication + if (isLoading) { + return ( + + + + Authentifizierung wird überprüft... + + + ); + } + + // If not authenticated, redirect to login + if (!isAuthenticated) { + return ; + } + + // User is authenticated, render children + return <>{children}; +}; + +export default ProtectedRoute; diff --git a/frontend/src/components/dashboard/ActivityFeed.tsx b/frontend/src/components/dashboard/ActivityFeed.tsx new file mode 100644 index 0000000..3b645c3 --- /dev/null +++ b/frontend/src/components/dashboard/ActivityFeed.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { + Card, + CardContent, + Typography, + List, + ListItem, + ListItemAvatar, + ListItemText, + Avatar, + Box, +} from '@mui/material'; +import { + LocalFireDepartment, + Person, + DirectionsCar, + Assignment, +} from '@mui/icons-material'; + +interface Activity { + id: string; + type: 'incident' | 'member' | 'vehicle' | 'task'; + title: string; + description: string; + timestamp: string; +} + +// Placeholder activities +const placeholderActivities: Activity[] = [ + { + id: '1', + type: 'incident', + title: 'Brandeinsatz', + description: 'Kleinbrand in der Hauptstraße', + timestamp: 'Vor 2 Stunden', + }, + { + id: '2', + type: 'member', + title: 'Neues Mitglied', + description: 'Max Mustermann ist der Feuerwehr beigetreten', + timestamp: 'Vor 5 Stunden', + }, + { + id: '3', + type: 'vehicle', + title: 'Fahrzeugwartung', + description: 'LF 16/12 - Wartung abgeschlossen', + timestamp: 'Gestern', + }, + { + id: '4', + type: 'task', + title: 'Aufgabe zugewiesen', + description: 'Neue Aufgabe: Inventur Atemschutzgeräte', + timestamp: 'Vor 2 Tagen', + }, +]; + +const ActivityFeed: React.FC = () => { + const getActivityIcon = (type: Activity['type']) => { + switch (type) { + case 'incident': + return ; + case 'member': + return ; + case 'vehicle': + return ; + case 'task': + return ; + default: + return ; + } + }; + + const getActivityColor = (type: Activity['type']) => { + switch (type) { + case 'incident': + return 'error.main'; + case 'member': + return 'success.main'; + case 'vehicle': + return 'warning.main'; + case 'task': + return 'info.main'; + default: + return 'primary.main'; + } + }; + + return ( + + + + Letzte Aktivitäten + + + + {placeholderActivities.map((activity, index) => ( + + + + + {getActivityIcon(activity.type)} + + + + {activity.title} + + } + secondary={ + + + {activity.description} + + + {activity.timestamp} + + + } + /> + + + ))} + + + {placeholderActivities.length === 0 && ( + + + Keine Aktivitäten vorhanden + + + )} + + + ); +}; + +export default ActivityFeed; diff --git a/frontend/src/components/dashboard/BookstackCard.tsx b/frontend/src/components/dashboard/BookstackCard.tsx new file mode 100644 index 0000000..f6cf279 --- /dev/null +++ b/frontend/src/components/dashboard/BookstackCard.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { MenuBook } from '@mui/icons-material'; +import ServiceCard from './ServiceCard'; + +interface BookstackCardProps { + onClick?: () => void; +} + +const BookstackCard: React.FC = ({ onClick }) => { + return ( + + ); +}; + +export default BookstackCard; diff --git a/frontend/src/components/dashboard/DashboardLayout.tsx b/frontend/src/components/dashboard/DashboardLayout.tsx new file mode 100644 index 0000000..86421ba --- /dev/null +++ b/frontend/src/components/dashboard/DashboardLayout.tsx @@ -0,0 +1,46 @@ +import { useState, ReactNode } from 'react'; +import { Box, Toolbar } from '@mui/material'; +import Header from '../shared/Header'; +import Sidebar from '../shared/Sidebar'; +import { useAuth } from '../../contexts/AuthContext'; +import Loading from '../shared/Loading'; + +interface DashboardLayoutProps { + children: ReactNode; +} + +function DashboardLayout({ children }: DashboardLayoutProps) { + const [mobileOpen, setMobileOpen] = useState(false); + const { isLoading } = useAuth(); + + const handleDrawerToggle = () => { + setMobileOpen(!mobileOpen); + }; + + if (isLoading) { + return ; + } + + return ( + +
+ setMobileOpen(false)} /> + + + + {children} + + + ); +} + +export default DashboardLayout; diff --git a/frontend/src/components/dashboard/NextcloudCard.tsx b/frontend/src/components/dashboard/NextcloudCard.tsx new file mode 100644 index 0000000..89c9c2d --- /dev/null +++ b/frontend/src/components/dashboard/NextcloudCard.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Cloud } from '@mui/icons-material'; +import ServiceCard from './ServiceCard'; + +interface NextcloudCardProps { + onClick?: () => void; +} + +const NextcloudCard: React.FC = ({ onClick }) => { + return ( + + ); +}; + +export default NextcloudCard; diff --git a/frontend/src/components/dashboard/ServiceCard.tsx b/frontend/src/components/dashboard/ServiceCard.tsx new file mode 100644 index 0000000..fc8922d --- /dev/null +++ b/frontend/src/components/dashboard/ServiceCard.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { + Card, + CardActionArea, + CardContent, + Typography, + Box, + Chip, +} from '@mui/material'; +import { SvgIconComponent } from '@mui/icons-material'; + +interface ServiceCardProps { + title: string; + description: string; + icon: SvgIconComponent; + status: 'connected' | 'disconnected'; + onClick?: () => void; +} + +const ServiceCard: React.FC = ({ + title, + description, + icon: Icon, + status, + onClick, +}) => { + const isConnected = status === 'connected'; + + return ( + + + + + + + + + + + + + {title} + + + + {description} + + + + + + + ); +}; + +export default ServiceCard; diff --git a/frontend/src/components/dashboard/StatsCard.tsx b/frontend/src/components/dashboard/StatsCard.tsx new file mode 100644 index 0000000..6c4a081 --- /dev/null +++ b/frontend/src/components/dashboard/StatsCard.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { Card, CardContent, Typography, Box } from '@mui/material'; +import { SvgIconComponent } from '@mui/icons-material'; + +interface StatsCardProps { + title: string; + value: string | number; + icon: SvgIconComponent; + color?: string; +} + +const StatsCard: React.FC = ({ + title, + value, + icon: Icon, + color = 'primary.main', +}) => { + return ( + + + + + + {title} + + + {value} + + + + + + + + + + ); +}; + +export default StatsCard; diff --git a/frontend/src/components/dashboard/UserProfile.tsx b/frontend/src/components/dashboard/UserProfile.tsx new file mode 100644 index 0000000..8ded6a6 --- /dev/null +++ b/frontend/src/components/dashboard/UserProfile.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { + Card, + CardContent, + Avatar, + Typography, + Box, + Chip, +} from '@mui/material'; +import { User } from '../../types/auth.types'; + +interface UserProfileProps { + user: User; +} + +const UserProfile: React.FC = ({ user }) => { + // Get first letter of name for avatar + const getInitials = (name: string): string => { + return name.charAt(0).toUpperCase(); + }; + + // Format date (placeholder until we have actual dates) + const formatDate = (date?: string): string => { + if (!date) return 'Nicht verfügbar'; + return new Date(date).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + }; + + return ( + + + + {/* Avatar */} + + {getInitials(user.name)} + + + {/* User Info */} + + + {user.name} + + + {user.email} + + {user.preferred_username && ( + + @{user.preferred_username} + + )} + + + + {user.groups && user.groups.length > 0 && ( + 1 ? 'n' : ''}`} + size="small" + sx={{ + bgcolor: 'rgba(255, 255, 255, 0.2)', + color: 'white', + }} + /> + )} + + + + {/* Additional Info */} + + + + Letzter Login + + + Heute + + + + + Mitglied seit + + + {formatDate()} + + + + + + + ); +}; + +export default UserProfile; diff --git a/frontend/src/components/dashboard/VikunjaCard.tsx b/frontend/src/components/dashboard/VikunjaCard.tsx new file mode 100644 index 0000000..3373e3f --- /dev/null +++ b/frontend/src/components/dashboard/VikunjaCard.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Assignment } from '@mui/icons-material'; +import ServiceCard from './ServiceCard'; + +interface VikunjaCardProps { + onClick?: () => void; +} + +const VikunjaCard: React.FC = ({ onClick }) => { + return ( + + ); +}; + +export default VikunjaCard; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts new file mode 100644 index 0000000..1de1dea --- /dev/null +++ b/frontend/src/components/dashboard/index.ts @@ -0,0 +1,8 @@ +export { default as UserProfile } from './UserProfile'; +export { default as ServiceCard } from './ServiceCard'; +export { default as NextcloudCard } from './NextcloudCard'; +export { default as VikunjaCard } from './VikunjaCard'; +export { default as BookstackCard } from './BookstackCard'; +export { default as StatsCard } from './StatsCard'; +export { default as ActivityFeed } from './ActivityFeed'; +export { default as DashboardLayout } from './DashboardLayout'; diff --git a/frontend/src/components/shared/EmptyState.tsx b/frontend/src/components/shared/EmptyState.tsx new file mode 100644 index 0000000..903aebd --- /dev/null +++ b/frontend/src/components/shared/EmptyState.tsx @@ -0,0 +1,55 @@ +import React, { ReactNode } from 'react'; +import { Box, Typography, Button } from '@mui/material'; + +interface EmptyStateProps { + icon: ReactNode; + title: string; + message: string; + action?: { + label: string; + onClick: () => void; + }; +} + +const EmptyState: React.FC = ({ icon, title, message, action }) => { + return ( + + + {icon} + + + {title} + + + {message} + + {action && ( + + )} + + ); +}; + +export default EmptyState; diff --git a/frontend/src/components/shared/ErrorBoundary.tsx b/frontend/src/components/shared/ErrorBoundary.tsx new file mode 100644 index 0000000..2c22357 --- /dev/null +++ b/frontend/src/components/shared/ErrorBoundary.tsx @@ -0,0 +1,137 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { Box, Card, CardContent, Typography, Button } from '@mui/material'; +import { ErrorOutline, Refresh } from '@mui/icons-material'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error('ErrorBoundary caught an error:', error); + console.error('Error Info:', errorInfo); + this.setState({ error, errorInfo }); + } + + handleReset = (): void => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + render(): ReactNode { + if (this.state.hasError) { + return ( + + + + + + Etwas ist schiefgelaufen + + + Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es + erneut. + + + {this.state.error && ( + + + {this.state.error.toString()} + + + )} + + + + + + ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/shared/Header.tsx b/frontend/src/components/shared/Header.tsx new file mode 100644 index 0000000..18d1d40 --- /dev/null +++ b/frontend/src/components/shared/Header.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import { + AppBar, + Toolbar, + Typography, + IconButton, + Menu, + MenuItem, + Avatar, + ListItemIcon, + Divider, + Box, +} from '@mui/material'; +import { + LocalFireDepartment, + Person, + Settings, + Logout, + Menu as MenuIcon, +} from '@mui/icons-material'; +import { useAuth } from '../../contexts/AuthContext'; +import { useNavigate } from 'react-router-dom'; + +interface HeaderProps { + onMenuClick: () => void; +} + +function Header({ onMenuClick }: HeaderProps) { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const [anchorEl, setAnchorEl] = useState(null); + + const handleMenuOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + }; + + const handleProfile = () => { + handleMenuClose(); + navigate('/profile'); + }; + + const handleSettings = () => { + handleMenuClose(); + navigate('/settings'); + }; + + const handleLogout = () => { + handleMenuClose(); + logout(); + }; + + // Get initials for avatar + const getInitials = () => { + if (!user) return '?'; + const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || ''); + return initials || user.name?.[0] || '?'; + }; + + return ( + theme.zIndex.drawer + 1, + }} + > + + + + + + + + Feuerwehr Dashboard + + + {user && ( + <> + + + {getInitials()} + + + + + + + {user.name} + + + {user.email} + + + + + + + + Profil + + + + + + Einstellungen + + + + + + + Abmelden + + + + )} + + + ); +} + +export default Header; diff --git a/frontend/src/components/shared/Loading.tsx b/frontend/src/components/shared/Loading.tsx new file mode 100644 index 0000000..6cbcd27 --- /dev/null +++ b/frontend/src/components/shared/Loading.tsx @@ -0,0 +1,29 @@ +import { Box, CircularProgress, Typography } from '@mui/material'; + +interface LoadingProps { + message?: string; +} + +function Loading({ message }: LoadingProps) { + return ( + + + {message && ( + + {message} + + )} + + ); +} + +export default Loading; diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx new file mode 100644 index 0000000..22a94c3 --- /dev/null +++ b/frontend/src/components/shared/Sidebar.tsx @@ -0,0 +1,156 @@ +import { + Drawer, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Toolbar, + Tooltip, +} from '@mui/material'; +import { + Dashboard as DashboardIcon, + LocalFireDepartment, + DirectionsCar, + Build, + People, +} from '@mui/icons-material'; +import { useNavigate, useLocation } from 'react-router-dom'; + +const DRAWER_WIDTH = 240; + +interface NavigationItem { + text: string; + icon: JSX.Element; + path: string; +} + +const navigationItems: NavigationItem[] = [ + { + text: 'Dashboard', + icon: , + path: '/dashboard', + }, + { + text: 'Einsätze', + icon: , + path: '/einsaetze', + }, + { + text: 'Fahrzeuge', + icon: , + path: '/fahrzeuge', + }, + { + text: 'Ausrüstung', + icon: , + path: '/ausruestung', + }, + { + text: 'Mitglieder', + icon: , + path: '/mitglieder', + }, +]; + +interface SidebarProps { + mobileOpen: boolean; + onMobileClose: () => void; +} + +function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { + const navigate = useNavigate(); + const location = useLocation(); + + const handleNavigation = (path: string) => { + navigate(path); + onMobileClose(); + }; + + const drawerContent = ( + <> + + + {navigationItems.map((item) => { + const isActive = location.pathname === item.path; + return ( + + + handleNavigation(item.path)} + aria-label={`Zu ${item.text} navigieren`} + sx={{ + '&.Mui-selected': { + backgroundColor: 'primary.light', + color: 'primary.contrastText', + '&:hover': { + backgroundColor: 'primary.main', + }, + '& .MuiListItemIcon-root': { + color: 'primary.contrastText', + }, + }, + }} + > + + {item.icon} + + + + + + ); + })} + + + ); + + return ( + <> + {/* Mobile drawer */} + + {drawerContent} + + + {/* Desktop drawer */} + + {drawerContent} + + + ); +} + +export default Sidebar; diff --git a/frontend/src/components/shared/SkeletonCard.tsx b/frontend/src/components/shared/SkeletonCard.tsx new file mode 100644 index 0000000..4fd9497 --- /dev/null +++ b/frontend/src/components/shared/SkeletonCard.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Card, CardContent, Skeleton, Box } from '@mui/material'; + +interface SkeletonCardProps { + variant?: 'basic' | 'withAvatar' | 'detailed'; +} + +const SkeletonCard: React.FC = ({ variant = 'basic' }) => { + return ( + + + {variant === 'withAvatar' && ( + + + + + + + + )} + + {variant === 'detailed' && ( + <> + + + + + + + )} + + {variant === 'basic' && ( + <> + + + + + )} + + + ); +}; + +export default SkeletonCard; diff --git a/frontend/src/components/shared/index.ts b/frontend/src/components/shared/index.ts new file mode 100644 index 0000000..8daf85f --- /dev/null +++ b/frontend/src/components/shared/index.ts @@ -0,0 +1,7 @@ +// Shared components barrel export +export { default as ErrorBoundary } from './ErrorBoundary'; +export { default as EmptyState } from './EmptyState'; +export { default as SkeletonCard } from './SkeletonCard'; +export { default as Header } from './Header'; +export { default as Sidebar } from './Sidebar'; +export { default as Loading } from './Loading'; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..d8cf20e --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,151 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { AuthContextType, AuthState, User } from '../types/auth.types'; +import { authService } from '../services/auth'; +import { getToken, setToken, removeToken, getUser, setUser, removeUser } from '../utils/storage'; +import { useNotification } from './NotificationContext'; + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: ReactNode; +} + +export const AuthProvider: React.FC = ({ children }) => { + const notification = useNotification(); + const [state, setState] = useState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: true, + }); + + // Check for existing token on mount + useEffect(() => { + const initializeAuth = async () => { + const token = getToken(); + const user = getUser(); + + if (token && user) { + setState({ + user, + token, + isAuthenticated: true, + isLoading: false, + }); + + // Optionally verify token is still valid + try { + await authService.getCurrentUser(); + } catch (error) { + console.error('Token validation failed:', error); + // Token is invalid, clear it + removeToken(); + removeUser(); + setState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + }); + } + } else { + setState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + }); + } + }; + + initializeAuth(); + }, []); + + const login = async (code: string): Promise => { + try { + setState((prev) => ({ ...prev, isLoading: true })); + + const { token, user } = await authService.handleCallback(code); + + // Save to localStorage + setToken(token); + setUser(user); + + // Update state + setState({ + user, + token, + isAuthenticated: true, + isLoading: false, + }); + + // Show success notification + notification.showSuccess('Anmeldung erfolgreich'); + } catch (error) { + console.error('Login failed:', error); + setState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + }); + + // Show error notification + notification.showError('Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.'); + throw error; + } + }; + + const logout = (): void => { + // Call backend logout (fire and forget) + authService.logout().catch((error) => { + console.error('Backend logout failed:', error); + }); + + // Clear local state + removeToken(); + removeUser(); + setState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + }); + + // Show logout notification + notification.showSuccess('Abmeldung erfolgreich'); + + // Redirect to login after a short delay to show notification + setTimeout(() => { + window.location.href = '/login'; + }, 1000); + }; + + const refreshAuth = async (): Promise => { + try { + const user = await authService.getCurrentUser(); + setUser(user); + setState((prev) => ({ ...prev, user })); + } catch (error) { + console.error('Failed to refresh user data:', error); + logout(); + } + }; + + const value: AuthContextType = { + ...state, + login, + logout, + refreshAuth, + }; + + return {children}; +}; + +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/frontend/src/contexts/NotificationContext.tsx b/frontend/src/contexts/NotificationContext.tsx new file mode 100644 index 0000000..6d91c2e --- /dev/null +++ b/frontend/src/contexts/NotificationContext.tsx @@ -0,0 +1,109 @@ +import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react'; +import { Snackbar, Alert, AlertColor } from '@mui/material'; + +interface Notification { + id: number; + message: string; + severity: AlertColor; +} + +interface NotificationContextType { + showSuccess: (message: string) => void; + showError: (message: string) => void; + showWarning: (message: string) => void; + showInfo: (message: string) => void; +} + +const NotificationContext = createContext(undefined); + +interface NotificationProviderProps { + children: ReactNode; +} + +export const NotificationProvider: React.FC = ({ children }) => { + const [notifications, setNotifications] = useState([]); + const [currentNotification, setCurrentNotification] = useState(null); + + const addNotification = useCallback((message: string, severity: AlertColor) => { + const id = Date.now(); + const notification: Notification = { id, message, severity }; + + setNotifications((prev) => [...prev, notification]); + + // If no notification is currently displayed, show this one immediately + if (!currentNotification) { + setCurrentNotification(notification); + } + }, [currentNotification]); + + const showSuccess = useCallback((message: string) => { + addNotification(message, 'success'); + }, [addNotification]); + + const showError = useCallback((message: string) => { + addNotification(message, 'error'); + }, [addNotification]); + + const showWarning = useCallback((message: string) => { + addNotification(message, 'warning'); + }, [addNotification]); + + const showInfo = useCallback((message: string) => { + addNotification(message, 'info'); + }, [addNotification]); + + const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => { + if (reason === 'clickaway') { + return; + } + + setCurrentNotification(null); + + // Show next notification after a short delay + setTimeout(() => { + setNotifications((prev) => { + const remaining = prev.filter((n) => n.id !== currentNotification?.id); + if (remaining.length > 0) { + setCurrentNotification(remaining[0]); + } + return remaining; + }); + }, 200); + }; + + const value: NotificationContextType = { + showSuccess, + showError, + showWarning, + showInfo, + }; + + return ( + + {children} + + + {currentNotification?.message} + + + + ); +}; + +export const useNotification = (): NotificationContextType => { + const context = useContext(NotificationContext); + if (context === undefined) { + throw new Error('useNotification must be used within a NotificationProvider'); + } + return context; +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..6ca4231 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { CssBaseline, ThemeProvider } from '@mui/material'; +import { lightTheme } from './theme/theme'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + , +); diff --git a/frontend/src/pages/Ausruestung.tsx b/frontend/src/pages/Ausruestung.tsx new file mode 100644 index 0000000..9fa1370 --- /dev/null +++ b/frontend/src/pages/Ausruestung.tsx @@ -0,0 +1,69 @@ +import { + Container, + Typography, + Card, + CardContent, + Box, +} from '@mui/material'; +import { Build } from '@mui/icons-material'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; + +function Ausruestung() { + return ( + + + + Ausrüstungsverwaltung + + + + + + + + Ausrüstung + + Diese Funktion wird in Kürze verfügbar sein + + + + + + Geplante Features: + +
    +
  • + + Inventarverwaltung + +
  • +
  • + + Wartungsprüfungen und -protokolle + +
  • +
  • + + Prüffristen und Erinnerungen + +
  • +
  • + + Schutzausrüstung (PSA) + +
  • +
  • + + Atemschutzgeräte und -wartung + +
  • +
+
+
+
+
+
+ ); +} + +export default Ausruestung; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..2a36666 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,203 @@ +import { useState, useEffect } from 'react'; +import { + Container, + Box, + Typography, + Grid, + Fade, +} from '@mui/material'; +import { + People, + Warning, + EventNote, + LocalFireDepartment, +} from '@mui/icons-material'; +import { useAuth } from '../contexts/AuthContext'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import SkeletonCard from '../components/shared/SkeletonCard'; +import UserProfile from '../components/dashboard/UserProfile'; +import NextcloudCard from '../components/dashboard/NextcloudCard'; +import VikunjaCard from '../components/dashboard/VikunjaCard'; +import BookstackCard from '../components/dashboard/BookstackCard'; +import StatsCard from '../components/dashboard/StatsCard'; +import ActivityFeed from '../components/dashboard/ActivityFeed'; + +function Dashboard() { + const { user } = useAuth(); + const [dataLoading, setDataLoading] = useState(true); + + useEffect(() => { + // Simulate loading data + const timer = setTimeout(() => { + setDataLoading(false); + }, 800); + + return () => clearTimeout(timer); + }, []); + + return ( + + + + {/* Welcome Message */} + + {dataLoading ? ( + + ) : ( + + + + Willkommen zurück, {user?.given_name || user?.name.split(' ')[0]}! + + + + )} + + + {/* User Profile Card */} + {user && ( + + {dataLoading ? ( + + ) : ( + + + + + + )} + + )} + + {/* Stats Cards Row */} + + {dataLoading ? ( + + ) : ( + + + + + + )} + + + {dataLoading ? ( + + ) : ( + + + + + + )} + + + {dataLoading ? ( + + ) : ( + + + + + + )} + + + {dataLoading ? ( + + ) : ( + + + + + + )} + + + {/* Service Integration Cards */} + + + Dienste und Integrationen + + + + + {dataLoading ? ( + + ) : ( + + + console.log('Nextcloud clicked')} + /> + + + )} + + + {dataLoading ? ( + + ) : ( + + + console.log('Vikunja clicked')} + /> + + + )} + + + {dataLoading ? ( + + ) : ( + + + console.log('Bookstack clicked')} + /> + + + )} + + + {/* Activity Feed */} + + {dataLoading ? ( + + ) : ( + + + + + + )} + + + + + ); +} + +export default Dashboard; diff --git a/frontend/src/pages/Einsaetze.tsx b/frontend/src/pages/Einsaetze.tsx new file mode 100644 index 0000000..b1362c9 --- /dev/null +++ b/frontend/src/pages/Einsaetze.tsx @@ -0,0 +1,69 @@ +import { + Container, + Typography, + Card, + CardContent, + Box, +} from '@mui/material'; +import { LocalFireDepartment } from '@mui/icons-material'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; + +function Einsaetze() { + return ( + + + + Einsatzübersicht + + + + + + + + Einsatzverwaltung + + Diese Funktion wird in Kürze verfügbar sein + + + + + + Geplante Features: + +
    +
  • + + Einsatzliste mit Filteroptionen + +
  • +
  • + + Einsatzberichte erstellen und verwalten + +
  • +
  • + + Statistiken und Auswertungen + +
  • +
  • + + Einsatzdokumentation + +
  • +
  • + + Alarmstufen und Kategorien + +
  • +
+
+
+
+
+
+ ); +} + +export default Einsaetze; diff --git a/frontend/src/pages/Fahrzeuge.tsx b/frontend/src/pages/Fahrzeuge.tsx new file mode 100644 index 0000000..15aad6a --- /dev/null +++ b/frontend/src/pages/Fahrzeuge.tsx @@ -0,0 +1,69 @@ +import { + Container, + Typography, + Card, + CardContent, + Box, +} from '@mui/material'; +import { DirectionsCar } from '@mui/icons-material'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; + +function Fahrzeuge() { + return ( + + + + Fahrzeugverwaltung + + + + + + + + Fahrzeuge + + Diese Funktion wird in Kürze verfügbar sein + + + + + + Geplante Features: + +
    +
  • + + Fahrzeugliste mit Details + +
  • +
  • + + Wartungspläne und -historie + +
  • +
  • + + Tankbuch und Kilometerstände + +
  • +
  • + + TÜV/HU Erinnerungen + +
  • +
  • + + Fahrzeugdokumentation + +
  • +
+
+
+
+
+
+ ); +} + +export default Fahrzeuge; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..903e513 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Container, + Box, + Paper, + Button, + Typography, + CircularProgress, + Fade, +} from '@mui/material'; +import { LocalFireDepartment, Login as LoginIcon } from '@mui/icons-material'; +import { authService } from '../services/auth'; +import { useAuth } from '../contexts/AuthContext'; + +function Login() { + const navigate = useNavigate(); + const { isAuthenticated, isLoading } = useAuth(); + const [isRedirecting, setIsRedirecting] = useState(false); + + // Redirect to dashboard if already authenticated + useEffect(() => { + if (isAuthenticated) { + setIsRedirecting(true); + navigate('/dashboard', { replace: true }); + } + }, [isAuthenticated, navigate]); + + const handleLogin = () => { + try { + const authUrl = authService.getAuthUrl(); + window.location.href = authUrl; + } catch (error) { + console.error('Failed to initiate login:', error); + } + }; + + if (isLoading || isRedirecting) { + return ( + + + + + {isRedirecting ? 'Weiterleitung...' : 'Lade...'} + + + + ); + } + + return ( + + + + + + + Feuerwehr Dashboard + + + Bitte melden Sie sich mit Ihrem Authentik-Konto an + + + + + + + + + Feuerwehr Dashboard v0.0.1 + + + {new Date().getFullYear()} + + + + + ); +} + +export default Login; diff --git a/frontend/src/pages/Mitglieder.tsx b/frontend/src/pages/Mitglieder.tsx new file mode 100644 index 0000000..be27a7a --- /dev/null +++ b/frontend/src/pages/Mitglieder.tsx @@ -0,0 +1,69 @@ +import { + Container, + Typography, + Card, + CardContent, + Box, +} from '@mui/material'; +import { People } from '@mui/icons-material'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; + +function Mitglieder() { + return ( + + + + Mitgliederverwaltung + + + + + + + + Mitglieder + + Diese Funktion wird in Kürze verfügbar sein + + + + + + Geplante Features: + +
    +
  • + + Mitgliederliste mit Kontaktdaten + +
  • +
  • + + Qualifikationen und Lehrgänge + +
  • +
  • + + Anwesenheitsverwaltung + +
  • +
  • + + Dienstpläne und -einteilungen + +
  • +
  • + + Atemschutz-G26 Untersuchungen + +
  • +
+
+
+
+
+
+ ); +} + +export default Mitglieder; diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx new file mode 100644 index 0000000..d0d90b2 --- /dev/null +++ b/frontend/src/pages/NotFound.tsx @@ -0,0 +1,50 @@ +import { useNavigate } from 'react-router-dom'; +import { Container, Box, Typography, Button, Paper } from '@mui/material'; +import { Home } from '@mui/icons-material'; + +function NotFound() { + const navigate = useNavigate(); + + return ( + + + + + 404 + + + Seite nicht gefunden + + + Die angeforderte Seite existiert nicht. + + + + + + ); +} + +export default NotFound; diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx new file mode 100644 index 0000000..b0fc07b --- /dev/null +++ b/frontend/src/pages/Profile.tsx @@ -0,0 +1,262 @@ +import { + Container, + Paper, + Box, + Typography, + Avatar, + Grid, + TextField, + Card, + CardContent, + Divider, + Chip, +} from '@mui/material'; +import { Person, Email, Badge, Group, AccessTime } from '@mui/icons-material'; +import { useAuth } from '../contexts/AuthContext'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; + +function Profile() { + const { user } = useAuth(); + + if (!user) { + return null; + } + + // Get initials for large avatar + const getInitials = () => { + const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || ''); + return initials || user.name?.[0] || '?'; + }; + + // Format date (if we had lastLogin) + const formatDate = (date?: Date | string) => { + if (!date) return 'Nicht verfügbar'; + const d = typeof date === 'string' ? new Date(date) : date; + return d.toLocaleDateString('de-DE', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( + + + + Mein Profil + + + + {/* User Info Card */} + + + + + + {getInitials()} + + + {user.name} + + + {user.email} + + {user.preferred_username && ( + + + @{user.preferred_username} + + )} + + + + + {/* Groups/Roles */} + {user.groups && user.groups.length > 0 && ( + + + + Gruppen + + + {user.groups.map((group) => ( + + ))} + + + )} + + + + + {/* Personal Information */} + + + + + Persönliche Informationen + + + + + + ), + }} + variant="outlined" + /> + + + + + ), + }} + variant="outlined" + /> + + + + + ), + }} + variant="outlined" + helperText="E-Mail-Adresse wird von Authentik verwaltet" + /> + + + {user.preferred_username && ( + + + ), + }} + variant="outlined" + /> + + )} + + + + + Diese Informationen werden von Authentik verwaltet und können hier nicht + bearbeitet werden. Bitte wenden Sie sich an Ihren Administrator, um + Änderungen vorzunehmen. + + + + + + {/* Activity Information */} + + + + Aktivitätsinformationen + + + + + + + + + Letzte Anmeldung + + + {formatDate(new Date())} + + + + + + + + + {/* User Preferences */} + + + + Benutzereinstellungen + + + + Kommende Features: Benachrichtigungseinstellungen, Anzeigeoptionen, + Spracheinstellungen + + + + + + + + ); +} + +export default Profile; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..4d78db9 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,209 @@ +import { useState } from 'react'; +import { + Container, + Typography, + Card, + CardContent, + Grid, + FormGroup, + FormControlLabel, + Switch, + Divider, + Box, + Button, +} from '@mui/material'; +import { Settings as SettingsIcon, Notifications, Palette, Language, Save } from '@mui/icons-material'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useNotification } from '../contexts/NotificationContext'; + +function Settings() { + const notification = useNotification(); + + // Settings state + const [emailNotifications, setEmailNotifications] = useState(true); + const [alarmNotifications, setAlarmNotifications] = useState(true); + const [maintenanceReminders, setMaintenanceReminders] = useState(false); + const [systemNotifications, setSystemNotifications] = useState(true); + const [darkMode, setDarkMode] = useState(false); + const [compactView, setCompactView] = useState(true); + const [animations, setAnimations] = useState(true); + + const handleSaveSettings = () => { + try { + // In a real application, save settings to backend + notification.showSuccess('Einstellungen erfolgreich gespeichert'); + } catch (error) { + notification.showError('Fehler beim Speichern der Einstellungen'); + } + }; + return ( + + + + Einstellungen + + + + {/* Notification Settings */} + + + + + + Benachrichtigungen + + + + setEmailNotifications(e.target.checked)} + /> + } + label="E-Mail-Benachrichtigungen" + /> + setAlarmNotifications(e.target.checked)} + /> + } + label="Einsatz-Alarme" + /> + setMaintenanceReminders(e.target.checked)} + /> + } + label="Wartungserinnerungen" + /> + setSystemNotifications(e.target.checked)} + /> + } + label="System-Benachrichtigungen" + /> + + + + + + {/* Display Settings */} + + + + + + Anzeigeoptionen + + + + { + setDarkMode(e.target.checked); + notification.showInfo('Dunkler Modus wird in einer zukünftigen Version verfügbar sein'); + }} + /> + } + label="Dunkler Modus (Vorschau)" + /> + setCompactView(e.target.checked)} + /> + } + label="Kompakte Ansicht" + /> + setAnimations(e.target.checked)} + /> + } + label="Animationen" + /> + + + + + + {/* Language Settings */} + + + + + + Sprache + + + + Aktuelle Sprache: Deutsch + + + Kommende Features: Sprachauswahl, Datumsformat, Zeitzone + + + + + + {/* General Settings */} + + + + + + Allgemein + + + + Kommende Features: Dashboard-Layout, Standardansichten, Exporteinstellungen + + + + + + + + + Diese Einstellungen sind derzeit nur zur Demonstration verfügbar. Die Funktionalität + wird in zukünftigen Updates implementiert. + + + + + + + + + ); +} + +export default Settings; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..6cb8ef4 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,102 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'; +import { API_URL } from '../utils/config'; +import { getToken, removeToken, removeUser } from '../utils/storage'; + +export interface ApiError { + message: string; + status?: number; + code?: string; +} + +class ApiService { + private axiosInstance: AxiosInstance; + + constructor() { + this.axiosInstance = axios.create({ + baseURL: API_URL, + timeout: 30000, // 30 seconds timeout + headers: { + 'Content-Type': 'application/json', + }, + }); + + // Request interceptor: Add Authorization header with JWT + this.axiosInstance.interceptors.request.use( + (config) => { + const token = getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + console.error('Request interceptor error:', error); + return Promise.reject(this.handleError(error)); + } + ); + + // Response interceptor: Handle errors + this.axiosInstance.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (error.response?.status === 401) { + // Clear tokens and redirect to login + console.warn('Unauthorized request, redirecting to login'); + removeToken(); + removeUser(); + window.location.href = '/login'; + } + return Promise.reject(this.handleError(error)); + } + ); + } + + private handleError(error: AxiosError): ApiError { + if (error.response) { + // Server responded with error + const message = (error.response.data as any)?.message || + (error.response.data as any)?.error || + error.message || + 'Ein Fehler ist aufgetreten'; + return { + message, + status: error.response.status, + code: error.code, + }; + } else if (error.request) { + // Request was made but no response received + return { + message: 'Keine Antwort vom Server. Bitte überprüfen Sie Ihre Internetverbindung.', + code: error.code, + }; + } else { + // Something else happened + return { + message: error.message || 'Ein unerwarteter Fehler ist aufgetreten', + code: error.code, + }; + } + } + + async get(url: string, config?: AxiosRequestConfig): Promise> { + return this.axiosInstance.get(url, config); + } + + async post(url: string, data?: any, config?: AxiosRequestConfig): Promise> { + return this.axiosInstance.post(url, data, config); + } + + async put(url: string, data?: any, config?: AxiosRequestConfig): Promise> { + return this.axiosInstance.put(url, data, config); + } + + async delete(url: string, config?: AxiosRequestConfig): Promise> { + return this.axiosInstance.delete(url, config); + } + + async patch(url: string, data?: any, config?: AxiosRequestConfig): Promise> { + return this.axiosInstance.patch(url, data, config); + } +} + +export const api = new ApiService(); diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts new file mode 100644 index 0000000..4bcb40e --- /dev/null +++ b/frontend/src/services/auth.ts @@ -0,0 +1,58 @@ +import { api } from './api'; +import { AUTHENTIK_URL, CLIENT_ID } from '../utils/config'; +import { User } from '../types/auth.types'; + +const REDIRECT_URI = `${window.location.origin}/auth/callback`; + +export interface AuthCallbackResponse { + token: string; + user: User; +} + +export const authService = { + /** + * Generate Authentik authorization URL + */ + getAuthUrl(): string { + const params = new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: 'openid profile email', + }); + + return `${AUTHENTIK_URL}/application/o/authorize/?${params.toString()}`; + }, + + /** + * Handle OAuth callback - send code to backend, receive JWT + */ + async handleCallback(code: string): Promise { + const response = await api.post('/api/auth/callback', { + code, + redirect_uri: REDIRECT_URI, + }); + return response.data; + }, + + /** + * Logout - clear tokens + */ + async logout(): Promise { + try { + // Optionally call backend logout endpoint + await api.post('/api/auth/logout'); + } catch (error) { + console.error('Error during logout:', error); + // Continue with logout even if backend call fails + } + }, + + /** + * Get current user information + */ + async getCurrentUser(): Promise { + const response = await api.get('/api/user/me'); + return response.data; + }, +}; diff --git a/frontend/src/theme/theme.ts b/frontend/src/theme/theme.ts new file mode 100644 index 0000000..74d1491 --- /dev/null +++ b/frontend/src/theme/theme.ts @@ -0,0 +1,178 @@ +import { createTheme, ThemeOptions } from '@mui/material/styles'; + +// Fire department red color palette +const primaryRed = { + main: '#d32f2f', + light: '#ff6659', + dark: '#9a0007', + contrastText: '#ffffff', +}; + +const secondaryBlue = { + main: '#1976d2', + light: '#63a4ff', + dark: '#004ba0', + contrastText: '#ffffff', +}; + +const lightThemeOptions: ThemeOptions = { + palette: { + mode: 'light', + primary: primaryRed, + secondary: secondaryBlue, + background: { + default: '#f5f5f5', + paper: '#ffffff', + }, + error: { + main: '#f44336', + }, + warning: { + main: '#ff9800', + }, + info: { + main: '#2196f3', + }, + success: { + main: '#4caf50', + }, + }, + typography: { + fontFamily: [ + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + ].join(','), + h1: { + fontSize: '2.5rem', + fontWeight: 600, + lineHeight: 1.2, + }, + h2: { + fontSize: '2rem', + fontWeight: 600, + lineHeight: 1.3, + }, + h3: { + fontSize: '1.75rem', + fontWeight: 600, + lineHeight: 1.4, + }, + h4: { + fontSize: '1.5rem', + fontWeight: 600, + lineHeight: 1.4, + }, + h5: { + fontSize: '1.25rem', + fontWeight: 600, + lineHeight: 1.5, + }, + h6: { + fontSize: '1rem', + fontWeight: 600, + lineHeight: 1.6, + }, + body1: { + fontSize: '1rem', + lineHeight: 1.5, + }, + body2: { + fontSize: '0.875rem', + lineHeight: 1.43, + }, + button: { + textTransform: 'none', + fontWeight: 500, + }, + }, + spacing: 8, + shape: { + borderRadius: 8, + }, + components: { + MuiButton: { + styleOverrides: { + root: { + borderRadius: 8, + padding: '8px 16px', + }, + contained: { + boxShadow: 'none', + '&:hover': { + boxShadow: '0 2px 4px rgba(0,0,0,0.2)', + }, + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + borderRadius: 12, + boxShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)', + transition: 'all 0.3s cubic-bezier(.25,.8,.25,1)', + '&:hover': { + boxShadow: '0 4px 8px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23)', + }, + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + borderRadius: 8, + }, + elevation1: { + boxShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)', + }, + elevation2: { + boxShadow: '0 3px 6px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.12)', + }, + elevation3: { + boxShadow: '0 4px 8px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23)', + }, + }, + }, + MuiAppBar: { + styleOverrides: { + root: { + boxShadow: '0 2px 4px rgba(0,0,0,0.1)', + }, + }, + }, + }, +}; + +const darkThemeOptions: ThemeOptions = { + ...lightThemeOptions, + palette: { + mode: 'dark', + primary: primaryRed, + secondary: secondaryBlue, + background: { + default: '#121212', + paper: '#1e1e1e', + }, + error: { + main: '#f44336', + }, + warning: { + main: '#ff9800', + }, + info: { + main: '#2196f3', + }, + success: { + main: '#4caf50', + }, + }, +}; + +export const lightTheme = createTheme(lightThemeOptions); +export const darkTheme = createTheme(darkThemeOptions); + +export default lightTheme; diff --git a/frontend/src/types/auth.types.ts b/frontend/src/types/auth.types.ts new file mode 100644 index 0000000..932f9c0 --- /dev/null +++ b/frontend/src/types/auth.types.ts @@ -0,0 +1,28 @@ +export interface User { + id: string; + email: string; + name: string; + given_name: string; + family_name: string; + preferred_username?: string; + groups?: string[]; +} + +export interface AuthTokens { + token: string; + refreshToken?: string; + expiresIn?: number; +} + +export interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + isLoading: boolean; +} + +export interface AuthContextType extends AuthState { + login: (code: string) => Promise; + logout: () => void; + refreshAuth: () => Promise; +} diff --git a/frontend/src/utils/config.ts b/frontend/src/utils/config.ts new file mode 100644 index 0000000..23947e6 --- /dev/null +++ b/frontend/src/utils/config.ts @@ -0,0 +1,9 @@ +export const config = { + apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000', + authentikUrl: import.meta.env.VITE_AUTHENTIK_URL || 'https://authentik.yourdomain.com', + clientId: import.meta.env.VITE_CLIENT_ID || 'your_client_id_here', +}; + +export const API_URL = config.apiUrl; +export const AUTHENTIK_URL = config.authentikUrl; +export const CLIENT_ID = config.clientId; diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts new file mode 100644 index 0000000..d0deff7 --- /dev/null +++ b/frontend/src/utils/storage.ts @@ -0,0 +1,56 @@ +import { User } from '../types/auth.types'; + +const TOKEN_KEY = 'auth_token'; +const USER_KEY = 'auth_user'; + +export const getToken = (): string | null => { + try { + return localStorage.getItem(TOKEN_KEY); + } catch (error) { + console.error('Error getting token from localStorage:', error); + return null; + } +}; + +export const setToken = (token: string): void => { + try { + localStorage.setItem(TOKEN_KEY, token); + } catch (error) { + console.error('Error setting token in localStorage:', error); + } +}; + +export const removeToken = (): void => { + try { + localStorage.removeItem(TOKEN_KEY); + } catch (error) { + console.error('Error removing token from localStorage:', error); + } +}; + +export const getUser = (): User | null => { + try { + const userStr = localStorage.getItem(USER_KEY); + if (!userStr) return null; + return JSON.parse(userStr) as User; + } catch (error) { + console.error('Error getting user from localStorage:', error); + return null; + } +}; + +export const setUser = (user: User): void => { + try { + localStorage.setItem(USER_KEY, JSON.stringify(user)); + } catch (error) { + console.error('Error setting user in localStorage:', error); + } +}; + +export const removeUser = (): void => { + try { + localStorage.removeItem(USER_KEY); + } catch (error) { + console.error('Error removing user from localStorage:', error); + } +}; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..0f4c900 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; + // Add more env variables as needed +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..f91e301 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path mapping */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4a298d2 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: true, + }, +});