initial wcag-ada
This commit is contained in:
parent
042b8cb83a
commit
d52cfe7de2
112 changed files with 9069 additions and 0 deletions
70
apps/wcag-ada/.dockerignore
Normal file
70
apps/wcag-ada/.dockerignore
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Dependencies
|
||||||
|
**/node_modules
|
||||||
|
**/.pnp
|
||||||
|
**/.pnp.js
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
**/coverage
|
||||||
|
**/.nyc_output
|
||||||
|
|
||||||
|
# Production builds
|
||||||
|
**/dist
|
||||||
|
**/build
|
||||||
|
**/.next
|
||||||
|
**/out
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
**/*.log
|
||||||
|
**/npm-debug.log*
|
||||||
|
**/yarn-debug.log*
|
||||||
|
**/yarn-error.log*
|
||||||
|
**/lerna-debug.log*
|
||||||
|
**/.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/.env.development.local
|
||||||
|
**/.env.test.local
|
||||||
|
**/.env.production.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
**/.idea
|
||||||
|
**/.vscode
|
||||||
|
**/*.swp
|
||||||
|
**/*.swo
|
||||||
|
**/*~
|
||||||
|
**/.DS_Store
|
||||||
|
|
||||||
|
# Git
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
**/Dockerfile
|
||||||
|
**/docker-compose*.yml
|
||||||
|
**/.dockerignore
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
**/README.md
|
||||||
|
**/CHANGELOG.md
|
||||||
|
**/docs
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
**/.eslintcache
|
||||||
|
**/.cache
|
||||||
|
**/tmp
|
||||||
|
**/.turbo
|
||||||
|
|
||||||
|
# Database
|
||||||
|
**/prisma/migrations/migration_lock.toml
|
||||||
|
**/*.db
|
||||||
|
**/*.db-journal
|
||||||
|
|
||||||
|
# Only for root level (not in apps/*)
|
||||||
|
/apps/stock
|
||||||
|
/apps/db-design
|
||||||
|
/apps/cline
|
||||||
|
/lib/!(service)
|
||||||
|
!/lib/service/core-config
|
||||||
|
!/lib/service/core-logger
|
||||||
61
apps/wcag-ada/.env.example
Normal file
61
apps/wcag-ada/.env.example
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Application Environment
|
||||||
|
NODE_ENV=development
|
||||||
|
APP_NAME=wcag-ada
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
API_PORT=3001
|
||||||
|
API_CORS_ORIGIN=http://localhost:5173
|
||||||
|
API_JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
API_JWT_EXPIRES_IN=7d
|
||||||
|
API_RATE_LIMIT=100
|
||||||
|
API_RATE_WINDOW=60000
|
||||||
|
|
||||||
|
# Worker Configuration
|
||||||
|
WORKER_PORT=3002
|
||||||
|
WORKER_CONCURRENCY=5
|
||||||
|
WORKER_QUEUE_NAME=accessibility-scans
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/wcag_ada
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# Scanner Configuration
|
||||||
|
SCANNER_HEADLESS=true
|
||||||
|
SCANNER_TIMEOUT=30000
|
||||||
|
SCANNER_VIEWPORT_WIDTH=1920
|
||||||
|
SCANNER_VIEWPORT_HEIGHT=1080
|
||||||
|
SCANNER_BLOCK_RESOURCES=true
|
||||||
|
SCANNER_BLOCK_PATTERNS=font,image
|
||||||
|
|
||||||
|
# Features
|
||||||
|
FEATURES_SCREENSHOTS=true
|
||||||
|
FEATURES_FIX_SUGGESTIONS=true
|
||||||
|
FEATURES_CUSTOM_RULES=true
|
||||||
|
FEATURES_SCHEDULED_SCANS=true
|
||||||
|
FEATURES_BULK_SCANNING=false
|
||||||
|
FEATURES_API_ACCESS=true
|
||||||
|
|
||||||
|
# Third-party Services (optional)
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM=noreply@wcag-ada.com
|
||||||
|
|
||||||
|
SLACK_WEBHOOK_URL=
|
||||||
|
SLACK_CHANNEL=#wcag-alerts
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# Scheduler
|
||||||
|
SCHEDULER_TIMEZONE=America/New_York
|
||||||
|
SCHEDULER_MAX_CONCURRENT_SCANS=3
|
||||||
|
SCHEDULER_RETRY_ATTEMPTS=3
|
||||||
|
SCHEDULER_RETRY_DELAY=5000
|
||||||
181
apps/wcag-ada/.gitlab-ci.yml
Normal file
181
apps/wcag-ada/.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- test
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
variables:
|
||||||
|
DOCKER_DRIVER: overlay2
|
||||||
|
DOCKER_TLS_CERTDIR: ""
|
||||||
|
REGISTRY: $CI_REGISTRY
|
||||||
|
API_IMAGE: $CI_REGISTRY_IMAGE/api
|
||||||
|
WORKER_IMAGE: $CI_REGISTRY_IMAGE/worker
|
||||||
|
DASHBOARD_IMAGE: $CI_REGISTRY_IMAGE/dashboard
|
||||||
|
|
||||||
|
# Cache node_modules between jobs
|
||||||
|
.node_cache: &node_cache
|
||||||
|
cache:
|
||||||
|
key: ${CI_COMMIT_REF_SLUG}
|
||||||
|
paths:
|
||||||
|
- node_modules/
|
||||||
|
- apps/wcag-ada/*/node_modules/
|
||||||
|
- .bun/
|
||||||
|
|
||||||
|
# Docker login before each job
|
||||||
|
before_script:
|
||||||
|
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
build:api:
|
||||||
|
stage: build
|
||||||
|
image: docker:24-dind
|
||||||
|
services:
|
||||||
|
- docker:24-dind
|
||||||
|
script:
|
||||||
|
- docker build -f apps/wcag-ada/api/Dockerfile -t $API_IMAGE:$CI_COMMIT_SHA .
|
||||||
|
- docker tag $API_IMAGE:$CI_COMMIT_SHA $API_IMAGE:latest
|
||||||
|
- docker push $API_IMAGE:$CI_COMMIT_SHA
|
||||||
|
- docker push $API_IMAGE:latest
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- merge_requests
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
|
|
||||||
|
build:worker:
|
||||||
|
stage: build
|
||||||
|
image: docker:24-dind
|
||||||
|
services:
|
||||||
|
- docker:24-dind
|
||||||
|
script:
|
||||||
|
- docker build -f apps/wcag-ada/worker/Dockerfile -t $WORKER_IMAGE:$CI_COMMIT_SHA .
|
||||||
|
- docker tag $WORKER_IMAGE:$CI_COMMIT_SHA $WORKER_IMAGE:latest
|
||||||
|
- docker push $WORKER_IMAGE:$CI_COMMIT_SHA
|
||||||
|
- docker push $WORKER_IMAGE:latest
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- merge_requests
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
|
|
||||||
|
build:dashboard:
|
||||||
|
stage: build
|
||||||
|
image: docker:24-dind
|
||||||
|
services:
|
||||||
|
- docker:24-dind
|
||||||
|
script:
|
||||||
|
- docker build -f apps/wcag-ada/dashboard/Dockerfile -t $DASHBOARD_IMAGE:$CI_COMMIT_SHA .
|
||||||
|
- docker tag $DASHBOARD_IMAGE:$CI_COMMIT_SHA $DASHBOARD_IMAGE:latest
|
||||||
|
- docker push $DASHBOARD_IMAGE:$CI_COMMIT_SHA
|
||||||
|
- docker push $DASHBOARD_IMAGE:latest
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- merge_requests
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
|
|
||||||
|
# Test stage
|
||||||
|
test:unit:
|
||||||
|
stage: test
|
||||||
|
image: oven/bun:1-alpine
|
||||||
|
<<: *node_cache
|
||||||
|
before_script:
|
||||||
|
- cd apps/wcag-ada
|
||||||
|
- bun install
|
||||||
|
script:
|
||||||
|
- bun test
|
||||||
|
only:
|
||||||
|
- merge_requests
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
|
|
||||||
|
test:lint:
|
||||||
|
stage: test
|
||||||
|
image: oven/bun:1-alpine
|
||||||
|
<<: *node_cache
|
||||||
|
before_script:
|
||||||
|
- cd apps/wcag-ada
|
||||||
|
- bun install
|
||||||
|
script:
|
||||||
|
- bun run lint
|
||||||
|
only:
|
||||||
|
- merge_requests
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
|
|
||||||
|
test:typecheck:
|
||||||
|
stage: test
|
||||||
|
image: oven/bun:1-alpine
|
||||||
|
<<: *node_cache
|
||||||
|
before_script:
|
||||||
|
- cd apps/wcag-ada
|
||||||
|
- bun install
|
||||||
|
script:
|
||||||
|
- bun run typecheck
|
||||||
|
only:
|
||||||
|
- merge_requests
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
|
|
||||||
|
# Deploy to staging
|
||||||
|
deploy:staging:
|
||||||
|
stage: deploy
|
||||||
|
image: bitnami/kubectl:latest
|
||||||
|
before_script:
|
||||||
|
- kubectl config set-cluster k8s --server="$KUBE_URL" --insecure-skip-tls-verify=true
|
||||||
|
- kubectl config set-credentials gitlab --token="$KUBE_TOKEN"
|
||||||
|
- kubectl config set-context default --cluster=k8s --user=gitlab
|
||||||
|
- kubectl config use-context default
|
||||||
|
script:
|
||||||
|
- kubectl apply -f apps/wcag-ada/k8s/namespace.yaml
|
||||||
|
- kubectl apply -f apps/wcag-ada/k8s/configmap.yaml
|
||||||
|
- kubectl apply -f apps/wcag-ada/k8s/secrets.yaml
|
||||||
|
- kubectl apply -f apps/wcag-ada/k8s/postgres.yaml
|
||||||
|
- kubectl apply -f apps/wcag-ada/k8s/redis.yaml
|
||||||
|
- |
|
||||||
|
kubectl set image deployment/wcag-ada-api api=$API_IMAGE:$CI_COMMIT_SHA -n wcag-ada
|
||||||
|
kubectl set image deployment/wcag-ada-worker worker=$WORKER_IMAGE:$CI_COMMIT_SHA -n wcag-ada
|
||||||
|
kubectl set image deployment/wcag-ada-dashboard dashboard=$DASHBOARD_IMAGE:$CI_COMMIT_SHA -n wcag-ada
|
||||||
|
- kubectl rollout status deployment/wcag-ada-api -n wcag-ada
|
||||||
|
- kubectl rollout status deployment/wcag-ada-worker -n wcag-ada
|
||||||
|
- kubectl rollout status deployment/wcag-ada-dashboard -n wcag-ada
|
||||||
|
environment:
|
||||||
|
name: staging
|
||||||
|
url: https://staging.wcag-ada.example.com
|
||||||
|
only:
|
||||||
|
- develop
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
|
|
||||||
|
# Deploy to production
|
||||||
|
deploy:production:
|
||||||
|
stage: deploy
|
||||||
|
image: bitnami/kubectl:latest
|
||||||
|
before_script:
|
||||||
|
- kubectl config set-cluster k8s --server="$KUBE_URL_PROD" --insecure-skip-tls-verify=true
|
||||||
|
- kubectl config set-credentials gitlab --token="$KUBE_TOKEN_PROD"
|
||||||
|
- kubectl config set-context default --cluster=k8s --user=gitlab
|
||||||
|
- kubectl config use-context default
|
||||||
|
script:
|
||||||
|
- kubectl apply -f apps/wcag-ada/k8s/namespace.yaml
|
||||||
|
- kubectl apply -f apps/wcag-ada/k8s/configmap.yaml
|
||||||
|
- kubectl apply -f apps/wcag-ada/k8s/secrets.yaml
|
||||||
|
- kubectl apply -f apps/wcag-ada/k8s/postgres.yaml
|
||||||
|
- kubectl apply -f apps/wcag-ada/k8s/redis.yaml
|
||||||
|
- |
|
||||||
|
kubectl set image deployment/wcag-ada-api api=$API_IMAGE:$CI_COMMIT_SHA -n wcag-ada
|
||||||
|
kubectl set image deployment/wcag-ada-worker worker=$WORKER_IMAGE:$CI_COMMIT_SHA -n wcag-ada
|
||||||
|
kubectl set image deployment/wcag-ada-dashboard dashboard=$DASHBOARD_IMAGE:$CI_COMMIT_SHA -n wcag-ada
|
||||||
|
- kubectl rollout status deployment/wcag-ada-api -n wcag-ada
|
||||||
|
- kubectl rollout status deployment/wcag-ada-worker -n wcag-ada
|
||||||
|
- kubectl rollout status deployment/wcag-ada-dashboard -n wcag-ada
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
url: https://wcag-ada.example.com
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
when: manual
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
328
apps/wcag-ada/DEPLOYMENT.md
Normal file
328
apps/wcag-ada/DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
# WCAG-ADA Deployment Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide covers deployment options for the WCAG-ADA Compliance Monitoring Platform.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Local Development](#local-development)
|
||||||
|
2. [Docker Deployment](#docker-deployment)
|
||||||
|
3. [Kubernetes Deployment](#kubernetes-deployment)
|
||||||
|
4. [GitLab CI/CD](#gitlab-cicd)
|
||||||
|
5. [Configuration Management](#configuration-management)
|
||||||
|
6. [Monitoring & Maintenance](#monitoring--maintenance)
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the setup script
|
||||||
|
./scripts/local-dev.sh
|
||||||
|
|
||||||
|
# Start all services
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Setup
|
||||||
|
|
||||||
|
1. **Start development databases:**
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure environment:**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Run migrations:**
|
||||||
|
```bash
|
||||||
|
cd api && bunx prisma migrate dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Start services:**
|
||||||
|
```bash
|
||||||
|
# Terminal 1 - API
|
||||||
|
cd api && bun run dev
|
||||||
|
|
||||||
|
# Terminal 2 - Worker
|
||||||
|
cd worker && bun run dev
|
||||||
|
|
||||||
|
# Terminal 3 - Dashboard
|
||||||
|
cd dashboard && bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
### Build Images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using the build script
|
||||||
|
./scripts/build-images.sh
|
||||||
|
|
||||||
|
# Or manually with docker-compose
|
||||||
|
docker-compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run with Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Docker Deployment
|
||||||
|
|
||||||
|
1. **Set environment variables:**
|
||||||
|
```bash
|
||||||
|
export API_JWT_SECRET=your-secret-key
|
||||||
|
export DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||||
|
# ... other required variables
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run with production config:**
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kubernetes Deployment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Kubernetes cluster (1.24+)
|
||||||
|
- kubectl configured
|
||||||
|
- Ingress controller installed
|
||||||
|
- cert-manager (for TLS)
|
||||||
|
|
||||||
|
### Deploy to Kubernetes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using the deploy script
|
||||||
|
./scripts/deploy-k8s.sh
|
||||||
|
|
||||||
|
# Or manually
|
||||||
|
kubectl apply -f k8s/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. **Update secrets:**
|
||||||
|
```bash
|
||||||
|
# Edit k8s/secrets.yaml with your values
|
||||||
|
# Then apply
|
||||||
|
kubectl apply -f k8s/secrets.yaml -n wcag-ada
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update ingress hosts:**
|
||||||
|
```bash
|
||||||
|
# Edit k8s/ingress.yaml
|
||||||
|
# Replace example.com with your domain
|
||||||
|
kubectl apply -f k8s/ingress.yaml -n wcag-ada
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scaling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scale API replicas
|
||||||
|
kubectl scale deployment wcag-ada-api --replicas=5 -n wcag-ada
|
||||||
|
|
||||||
|
# Scale Worker replicas
|
||||||
|
kubectl scale deployment wcag-ada-worker --replicas=3 -n wcag-ada
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitLab CI/CD
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. **Configure GitLab Container Registry:**
|
||||||
|
- Enable container registry in project settings
|
||||||
|
- Note your registry URL
|
||||||
|
|
||||||
|
2. **Add CI/CD Variables:**
|
||||||
|
```
|
||||||
|
CI_REGISTRY_USER - GitLab username
|
||||||
|
CI_REGISTRY_PASSWORD - GitLab access token
|
||||||
|
KUBE_URL - Kubernetes API URL (staging)
|
||||||
|
KUBE_TOKEN - Kubernetes service account token (staging)
|
||||||
|
KUBE_URL_PROD - Kubernetes API URL (production)
|
||||||
|
KUBE_TOKEN_PROD - Kubernetes service account token (production)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure environments:**
|
||||||
|
- Create `staging` and `production` environments in GitLab
|
||||||
|
- Set appropriate URLs
|
||||||
|
|
||||||
|
### Deployment Flow
|
||||||
|
|
||||||
|
1. **Development:**
|
||||||
|
- Push to `develop` branch
|
||||||
|
- Automatically builds and deploys to staging
|
||||||
|
|
||||||
|
2. **Production:**
|
||||||
|
- Merge to `main` branch
|
||||||
|
- Builds images automatically
|
||||||
|
- Manual approval required for production deployment
|
||||||
|
|
||||||
|
## Configuration Management
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Key configuration variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Application
|
||||||
|
NODE_ENV=production
|
||||||
|
APP_NAME=wcag-ada
|
||||||
|
|
||||||
|
# API
|
||||||
|
API_PORT=3001
|
||||||
|
API_JWT_SECRET=<strong-secret>
|
||||||
|
API_CORS_ORIGIN=https://your-domain.com
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://user:password@host:5432/wcag_ada
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=<redis-password>
|
||||||
|
|
||||||
|
# Worker
|
||||||
|
WORKER_CONCURRENCY=5
|
||||||
|
WORKER_QUEUE_NAME=accessibility-scans
|
||||||
|
|
||||||
|
# Scanner
|
||||||
|
SCANNER_HEADLESS=true
|
||||||
|
SCANNER_TIMEOUT=30000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Priority
|
||||||
|
|
||||||
|
1. Command-line arguments (highest)
|
||||||
|
2. Environment variables
|
||||||
|
3. Configuration files
|
||||||
|
4. Default values (lowest)
|
||||||
|
|
||||||
|
### Secrets Management
|
||||||
|
|
||||||
|
- **Development:** Use `.env` files
|
||||||
|
- **Docker:** Use environment variables or Docker secrets
|
||||||
|
- **Kubernetes:** Use Kubernetes secrets
|
||||||
|
- **Production:** Consider using:
|
||||||
|
- HashiCorp Vault
|
||||||
|
- AWS Secrets Manager
|
||||||
|
- Azure Key Vault
|
||||||
|
- GitLab CI/CD variables
|
||||||
|
|
||||||
|
## Monitoring & Maintenance
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
All services expose health endpoints:
|
||||||
|
|
||||||
|
- API: `http://api:3001/health`
|
||||||
|
- Worker: `http://worker:3002/health`
|
||||||
|
- Dashboard: `http://dashboard:8080/health`
|
||||||
|
|
||||||
|
### Monitoring Stack
|
||||||
|
|
||||||
|
Recommended monitoring setup:
|
||||||
|
|
||||||
|
1. **Metrics:** Prometheus + Grafana
|
||||||
|
2. **Logs:** ELK Stack or Loki
|
||||||
|
3. **Traces:** Jaeger or Zipkin
|
||||||
|
4. **Uptime:** Uptime Kuma or Pingdom
|
||||||
|
|
||||||
|
### Database Maintenance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup PostgreSQL
|
||||||
|
kubectl exec -it postgres-pod -n wcag-ada -- pg_dump -U wcag_user wcag_ada > backup.sql
|
||||||
|
|
||||||
|
# Backup Redis
|
||||||
|
kubectl exec -it redis-pod -n wcag-ada -- redis-cli BGSAVE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updates and Migrations
|
||||||
|
|
||||||
|
1. **Database migrations:**
|
||||||
|
```bash
|
||||||
|
# Run migrations before deployment
|
||||||
|
kubectl run migration --rm -it --image=wcag-ada/api:latest -- bunx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Rolling updates:**
|
||||||
|
```bash
|
||||||
|
# Update image
|
||||||
|
kubectl set image deployment/wcag-ada-api api=wcag-ada/api:new-version -n wcag-ada
|
||||||
|
|
||||||
|
# Monitor rollout
|
||||||
|
kubectl rollout status deployment/wcag-ada-api -n wcag-ada
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
1. **View logs:**
|
||||||
|
```bash
|
||||||
|
# Kubernetes
|
||||||
|
kubectl logs -f deployment/wcag-ada-api -n wcag-ada
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Debug pods:**
|
||||||
|
```bash
|
||||||
|
# Get pod details
|
||||||
|
kubectl describe pod <pod-name> -n wcag-ada
|
||||||
|
|
||||||
|
# Execute commands in pod
|
||||||
|
kubectl exec -it <pod-name> -n wcag-ada -- /bin/sh
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Common issues:**
|
||||||
|
- Database connection: Check DATABASE_URL and network connectivity
|
||||||
|
- Redis connection: Verify Redis is running and accessible
|
||||||
|
- Browser issues: Ensure Chromium dependencies are installed
|
||||||
|
- Memory issues: Increase resource limits in Kubernetes
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Network Security:**
|
||||||
|
- Use NetworkPolicies in Kubernetes
|
||||||
|
- Implement proper firewall rules
|
||||||
|
- Use TLS for all external communications
|
||||||
|
|
||||||
|
2. **Application Security:**
|
||||||
|
- Rotate JWT secrets regularly
|
||||||
|
- Use strong passwords for databases
|
||||||
|
- Implement rate limiting
|
||||||
|
- Enable CORS appropriately
|
||||||
|
|
||||||
|
3. **Container Security:**
|
||||||
|
- Run containers as non-root user
|
||||||
|
- Use minimal base images
|
||||||
|
- Scan images for vulnerabilities
|
||||||
|
- Keep dependencies updated
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
1. Check application logs
|
||||||
|
2. Review health check endpoints
|
||||||
|
3. Consult error messages in dashboard
|
||||||
|
4. Check GitLab CI/CD pipeline status
|
||||||
85
apps/wcag-ada/Dockerfile
Normal file
85
apps/wcag-ada/Dockerfile
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# This is a multi-service Dockerfile that builds all services
|
||||||
|
# Use with --target flag to build specific services
|
||||||
|
|
||||||
|
# Base stage for all services
|
||||||
|
FROM oven/bun:1-alpine as base
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dependencies stage
|
||||||
|
FROM base as deps
|
||||||
|
COPY package.json bun.lockb ./
|
||||||
|
COPY apps/wcag-ada/*/package.json ./apps/wcag-ada/
|
||||||
|
COPY lib/service/core-config/package.json ./lib/service/core-config/
|
||||||
|
COPY lib/service/core-logger/package.json ./lib/service/core-logger/
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Builder stage
|
||||||
|
FROM base as builder
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/package.json ./package.json
|
||||||
|
COPY . .
|
||||||
|
WORKDIR /app/apps/wcag-ada
|
||||||
|
|
||||||
|
# Generate Prisma clients
|
||||||
|
RUN cd api && bunx prisma generate
|
||||||
|
RUN cd worker && bunx prisma generate
|
||||||
|
|
||||||
|
# Build all services
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# API Runtime
|
||||||
|
FROM base as api
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/api/dist ./dist
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/api/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/api/prisma ./prisma
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/api/package.json ./
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||||
|
RUN chown -R nodejs:nodejs /app
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1
|
||||||
|
CMD ["bun", "run", "start"]
|
||||||
|
|
||||||
|
# Worker Runtime
|
||||||
|
FROM base as worker
|
||||||
|
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
|
||||||
|
|
||||||
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||||
|
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser \
|
||||||
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true \
|
||||||
|
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||||
|
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/worker/dist ./dist
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/worker/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/worker/prisma ./prisma
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/worker/package.json ./
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||||
|
RUN chown -R nodejs:nodejs /app
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
EXPOSE 3002
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/health || exit 1
|
||||||
|
CMD ["bun", "run", "start"]
|
||||||
|
|
||||||
|
# Dashboard Runtime
|
||||||
|
FROM nginx:alpine as dashboard
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
COPY apps/wcag-ada/dashboard/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/dashboard/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||||
|
RUN chown -R nodejs:nodejs /usr/share/nginx/html /var/cache/nginx /var/log/nginx /etc/nginx/conf.d
|
||||||
|
RUN touch /var/run/nginx.pid && chown -R nodejs:nodejs /var/run/nginx.pid
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/ || exit 1
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
85
apps/wcag-ada/Makefile
Normal file
85
apps/wcag-ada/Makefile
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Show this help message
|
||||||
|
@echo 'Usage: make [target]'
|
||||||
|
@echo ''
|
||||||
|
@echo 'Targets:'
|
||||||
|
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
.PHONY: dev-up
|
||||||
|
dev-up: ## Start development databases (PostgreSQL and Redis)
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
.PHONY: dev-down
|
||||||
|
dev-down: ## Stop development databases
|
||||||
|
docker-compose -f docker-compose.dev.yml down
|
||||||
|
|
||||||
|
.PHONY: dev-logs
|
||||||
|
dev-logs: ## Show development database logs
|
||||||
|
docker-compose -f docker-compose.dev.yml logs -f
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: ## Build all Docker images
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
.PHONY: up
|
||||||
|
up: ## Start all services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
.PHONY: down
|
||||||
|
down: ## Stop all services
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
.PHONY: logs
|
||||||
|
logs: ## Show logs for all services
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
.PHONY: logs-api
|
||||||
|
logs-api: ## Show API service logs
|
||||||
|
docker-compose logs -f api
|
||||||
|
|
||||||
|
.PHONY: logs-worker
|
||||||
|
logs-worker: ## Show worker service logs
|
||||||
|
docker-compose logs -f worker
|
||||||
|
|
||||||
|
.PHONY: restart
|
||||||
|
restart: ## Restart all services
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
.PHONY: restart-api
|
||||||
|
restart-api: ## Restart API service
|
||||||
|
docker-compose restart api
|
||||||
|
|
||||||
|
.PHONY: restart-worker
|
||||||
|
restart-worker: ## Restart worker service
|
||||||
|
docker-compose restart worker
|
||||||
|
|
||||||
|
.PHONY: ps
|
||||||
|
ps: ## Show running services
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
.PHONY: exec-api
|
||||||
|
exec-api: ## Execute bash in API container
|
||||||
|
docker-compose exec api /bin/sh
|
||||||
|
|
||||||
|
.PHONY: exec-worker
|
||||||
|
exec-worker: ## Execute bash in worker container
|
||||||
|
docker-compose exec worker /bin/sh
|
||||||
|
|
||||||
|
.PHONY: migrate
|
||||||
|
migrate: ## Run database migrations
|
||||||
|
docker-compose run --rm migrate
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean: ## Remove all containers and volumes
|
||||||
|
docker-compose down -v
|
||||||
|
|
||||||
|
.PHONY: rebuild
|
||||||
|
rebuild: clean build up ## Clean, rebuild, and start all services
|
||||||
|
|
||||||
|
.PHONY: test-scan
|
||||||
|
test-scan: ## Run a test accessibility scan
|
||||||
|
@echo "Running test scan on example.com..."
|
||||||
|
@curl -X POST http://localhost:3001/api/scans \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-d '{"websiteId": "test-website-id", "url": "https://example.com"}'
|
||||||
175
apps/wcag-ada/README.md
Normal file
175
apps/wcag-ada/README.md
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
# WCAG-ADA Compliance Monitoring Platform
|
||||||
|
|
||||||
|
An automated web accessibility compliance monitoring system that continuously scans websites for WCAG and ADA compliance issues, provides detailed reports, and offers remediation suggestions.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The platform consists of four main services:
|
||||||
|
|
||||||
|
### 1. **Scanner Service** (`/scanner`)
|
||||||
|
- Performs accessibility scans using axe-core and Playwright
|
||||||
|
- Supports WCAG 2.0, 2.1, and 2.2 compliance levels (A, AA, AAA)
|
||||||
|
- Generates detailed violation reports with fix suggestions
|
||||||
|
- Captures screenshots of accessibility issues
|
||||||
|
|
||||||
|
### 2. **API Service** (`/api`)
|
||||||
|
- RESTful API built with Hono framework
|
||||||
|
- JWT-based authentication
|
||||||
|
- Redis-backed rate limiting
|
||||||
|
- PostgreSQL database with Prisma ORM
|
||||||
|
- BullMQ integration for job queuing
|
||||||
|
|
||||||
|
### 3. **Worker Service** (`/worker`)
|
||||||
|
- Processes scan jobs from the queue
|
||||||
|
- Runs scheduled scans based on website configurations
|
||||||
|
- Handles scan result processing and storage
|
||||||
|
- Provides health monitoring endpoints
|
||||||
|
|
||||||
|
### 4. **Dashboard** (`/dashboard`)
|
||||||
|
- React 18 SPA with Vite
|
||||||
|
- Real-time scan monitoring
|
||||||
|
- Compliance reporting and analytics
|
||||||
|
- Website and scan management
|
||||||
|
- Built with TailwindCSS and Radix UI
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The application uses a sophisticated configuration system that loads settings from multiple sources:
|
||||||
|
|
||||||
|
1. Default values (defined in schemas)
|
||||||
|
2. Configuration files (`.env`, `config.json`)
|
||||||
|
3. Environment variables
|
||||||
|
4. Command-line arguments
|
||||||
|
|
||||||
|
### Key Configuration Files
|
||||||
|
|
||||||
|
- `/config/src/schemas/` - Zod schemas defining all configuration options
|
||||||
|
- `/config/src/config-instance.ts` - Main configuration loader
|
||||||
|
- `.env` - Environment-specific settings
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- PostgreSQL
|
||||||
|
- Redis
|
||||||
|
- Bun (recommended) or npm/yarn
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set up the database:
|
||||||
|
```bash
|
||||||
|
cd apps/wcag-ada/api
|
||||||
|
bunx prisma migrate dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Configure environment variables:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Services
|
||||||
|
|
||||||
|
Start all services concurrently:
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or run individual services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API
|
||||||
|
cd apps/wcag-ada/api
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Worker
|
||||||
|
cd apps/wcag-ada/worker
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Dashboard
|
||||||
|
cd apps/wcag-ada/dashboard
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Scanner (for testing)
|
||||||
|
cd apps/wcag-ada/scanner
|
||||||
|
bun run example
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/auth/register` - Register new user
|
||||||
|
- `POST /api/auth/login` - User login
|
||||||
|
- `GET /api/auth/me` - Get current user
|
||||||
|
|
||||||
|
### Websites
|
||||||
|
- `GET /api/websites` - List user's websites
|
||||||
|
- `POST /api/websites` - Add new website
|
||||||
|
- `PUT /api/websites/:id` - Update website
|
||||||
|
- `DELETE /api/websites/:id` - Delete website
|
||||||
|
|
||||||
|
### Scans
|
||||||
|
- `POST /api/scans` - Start new scan
|
||||||
|
- `GET /api/scans/:id` - Get scan status
|
||||||
|
- `GET /api/scans/:id/result` - Get scan results
|
||||||
|
- `GET /api/scans/:id/violations` - Get violations
|
||||||
|
- `DELETE /api/scans/:id` - Cancel scan
|
||||||
|
|
||||||
|
### Reports
|
||||||
|
- `GET /api/reports/compliance/:websiteId` - Get compliance report
|
||||||
|
- `GET /api/reports/trends/:websiteId` - Get trend analysis
|
||||||
|
|
||||||
|
### Health
|
||||||
|
- `GET /health` - Service health check
|
||||||
|
- `GET /health/stats` - Detailed statistics
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/wcag-ada/
|
||||||
|
├── api/ # REST API service
|
||||||
|
├── config/ # Configuration management
|
||||||
|
├── dashboard/ # React web interface
|
||||||
|
├── scanner/ # Accessibility scanning engine
|
||||||
|
├── shared/ # Shared types and utilities
|
||||||
|
└── worker/ # Background job processor
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Run tests for all services:
|
||||||
|
```bash
|
||||||
|
bun test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The application is designed to be deployed as separate microservices. Each service can be containerized and deployed independently.
|
||||||
|
|
||||||
|
### Docker Support
|
||||||
|
|
||||||
|
Build Docker images:
|
||||||
|
```bash
|
||||||
|
docker build -t wcag-ada-api -f apps/wcag-ada/api/Dockerfile .
|
||||||
|
docker build -t wcag-ada-worker -f apps/wcag-ada/worker/Dockerfile .
|
||||||
|
docker build -t wcag-ada-dashboard -f apps/wcag-ada/dashboard/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Proprietary - All rights reserved
|
||||||
40
apps/wcag-ada/api/.env.example
Normal file
40
apps/wcag-ada/api/.env.example
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Database URL (required for Prisma)
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/wcag_ada
|
||||||
|
|
||||||
|
# Environment variables for configuration overrides
|
||||||
|
# These follow the WCAG_ prefix convention
|
||||||
|
|
||||||
|
# Override service ports
|
||||||
|
# WCAG_SERVICES_API_PORT=3001
|
||||||
|
# WCAG_SERVICES_DASHBOARD_PORT=3000
|
||||||
|
# WCAG_SERVICES_WORKER_PORT=3002
|
||||||
|
|
||||||
|
# Override database settings
|
||||||
|
# WCAG_DATABASE_POSTGRES_HOST=localhost
|
||||||
|
# WCAG_DATABASE_POSTGRES_PORT=5432
|
||||||
|
# WCAG_DATABASE_POSTGRES_DATABASE=wcag_ada
|
||||||
|
|
||||||
|
# Override Redis settings
|
||||||
|
# WCAG_WORKER_REDIS_HOST=localhost
|
||||||
|
# WCAG_WORKER_REDIS_PORT=6379
|
||||||
|
# WCAG_WORKER_REDIS_PASSWORD=
|
||||||
|
# WCAG_WORKER_REDIS_DB=2
|
||||||
|
|
||||||
|
# Override JWT settings
|
||||||
|
# WCAG_PROVIDERS_AUTH_JWT_SECRET=your-super-secret-key
|
||||||
|
# WCAG_PROVIDERS_AUTH_JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Override scanner settings
|
||||||
|
# WCAG_SCANNER_CONCURRENCY=2
|
||||||
|
# WCAG_SCANNER_TIMEOUT=120000
|
||||||
|
# WCAG_SCANNER_HEADLESS=true
|
||||||
|
|
||||||
|
# Override storage settings
|
||||||
|
# WCAG_PROVIDERS_STORAGE_TYPE=local
|
||||||
|
# WCAG_PROVIDERS_STORAGE_LOCAL_BASE_PATH=/tmp/wcag-ada
|
||||||
|
|
||||||
|
# Override email settings
|
||||||
|
# WCAG_PROVIDERS_EMAIL_SMTP_HOST=localhost
|
||||||
|
# WCAG_PROVIDERS_EMAIL_SMTP_PORT=587
|
||||||
|
# WCAG_PROVIDERS_EMAIL_SMTP_AUTH_USER=
|
||||||
|
# WCAG_PROVIDERS_EMAIL_SMTP_AUTH_PASS=
|
||||||
66
apps/wcag-ada/api/Dockerfile
Normal file
66
apps/wcag-ada/api/Dockerfile
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Build stage
|
||||||
|
FROM oven/bun:1-alpine as builder
|
||||||
|
|
||||||
|
# Install dependencies for Prisma
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy workspace files
|
||||||
|
COPY package.json bun.lockb ./
|
||||||
|
COPY apps/wcag-ada/api/package.json ./apps/wcag-ada/api/
|
||||||
|
COPY apps/wcag-ada/config/package.json ./apps/wcag-ada/config/
|
||||||
|
COPY apps/wcag-ada/shared/package.json ./apps/wcag-ada/shared/
|
||||||
|
COPY lib/service/core-config/package.json ./lib/service/core-config/
|
||||||
|
COPY lib/service/core-logger/package.json ./lib/service/core-logger/
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY apps/wcag-ada/api ./apps/wcag-ada/api
|
||||||
|
COPY apps/wcag-ada/config ./apps/wcag-ada/config
|
||||||
|
COPY apps/wcag-ada/shared ./apps/wcag-ada/shared
|
||||||
|
COPY lib/service/core-config ./lib/service/core-config
|
||||||
|
COPY lib/service/core-logger ./lib/service/core-logger
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
WORKDIR /app/apps/wcag-ada/api
|
||||||
|
RUN bunx prisma generate
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM oven/bun:1-alpine
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/api/dist ./dist
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/api/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/api/prisma ./prisma
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/api/package.json ./
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
RUN chown -R nodejs:nodejs /app
|
||||||
|
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["bun", "run", "start"]
|
||||||
34
apps/wcag-ada/api/package.json
Normal file
34
apps/wcag-ada/api/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "@wcag-ada/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --hot src/index.ts",
|
||||||
|
"build": "tsc -b",
|
||||||
|
"start": "bun run dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^3.11.7",
|
||||||
|
"@hono/node-server": "^1.4.0",
|
||||||
|
"@hono/zod-validator": "^0.5.0",
|
||||||
|
"zod": "^3.22.4",
|
||||||
|
"@wcag-ada/shared": "workspace:*",
|
||||||
|
"@wcag-ada/scanner": "workspace:*",
|
||||||
|
"@wcag-ada/config": "workspace:*",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"nanoid": "^5.0.4",
|
||||||
|
"@prisma/client": "^5.8.0",
|
||||||
|
"bullmq": "^5.1.1",
|
||||||
|
"ioredis": "^5.3.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.5",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"prisma": "^5.8.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
153
apps/wcag-ada/api/prisma/schema.prisma
Normal file
153
apps/wcag-ada/api/prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
name String?
|
||||||
|
company String?
|
||||||
|
role UserRole @default(USER)
|
||||||
|
apiKey String? @unique
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
websites Website[]
|
||||||
|
reports Report[]
|
||||||
|
scanJobs ScanJob[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Website {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
url String
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
scanSchedule Json? // ScanSchedule type
|
||||||
|
lastScanAt DateTime?
|
||||||
|
complianceScore Float?
|
||||||
|
tags String[]
|
||||||
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
authConfig Json? // AuthenticationConfig type
|
||||||
|
scanOptions Json? // Default scan options
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
scanResults ScanResult[]
|
||||||
|
scanJobs ScanJob[]
|
||||||
|
reports Report[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([url])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ScanResult {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
websiteId String
|
||||||
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
jobId String? @unique
|
||||||
|
job ScanJob? @relation(fields: [jobId], references: [id])
|
||||||
|
|
||||||
|
url String
|
||||||
|
scanDuration Int
|
||||||
|
summary Json // ScanSummary type
|
||||||
|
violations Json // AccessibilityViolation[] type
|
||||||
|
passes Json // AxeResult[] type
|
||||||
|
incomplete Json // AxeResult[] type
|
||||||
|
inapplicable Json // AxeResult[] type
|
||||||
|
pageMetadata Json // PageMetadata type
|
||||||
|
wcagCompliance Json // WCAGCompliance type
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([websiteId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ScanJob {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
websiteId String
|
||||||
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
url String
|
||||||
|
status JobStatus @default(PENDING)
|
||||||
|
options Json // AccessibilityScanOptions type
|
||||||
|
|
||||||
|
scheduledAt DateTime @default(now())
|
||||||
|
startedAt DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
error String?
|
||||||
|
retryCount Int @default(0)
|
||||||
|
maxRetries Int @default(3)
|
||||||
|
|
||||||
|
result ScanResult?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([websiteId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([scheduledAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Report {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
websiteId String
|
||||||
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
type ReportType
|
||||||
|
format ReportFormat
|
||||||
|
period Json // ReportPeriod type
|
||||||
|
|
||||||
|
summary Json // ReportSummary type
|
||||||
|
data Json // Report data
|
||||||
|
|
||||||
|
fileUrl String?
|
||||||
|
|
||||||
|
generatedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([websiteId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([generatedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
USER
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum JobStatus {
|
||||||
|
PENDING
|
||||||
|
RUNNING
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReportType {
|
||||||
|
COMPLIANCE
|
||||||
|
EXECUTIVE
|
||||||
|
TECHNICAL
|
||||||
|
TREND
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReportFormat {
|
||||||
|
PDF
|
||||||
|
HTML
|
||||||
|
JSON
|
||||||
|
CSV
|
||||||
|
}
|
||||||
39
apps/wcag-ada/api/src/config.ts
Normal file
39
apps/wcag-ada/api/src/config.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { initializeWcagConfig, getWcagConfig } from '@wcag-ada/config';
|
||||||
|
|
||||||
|
// Initialize configuration for API service
|
||||||
|
const appConfig = initializeWcagConfig('api');
|
||||||
|
|
||||||
|
// Export a config object that matches the expected interface
|
||||||
|
export const config = {
|
||||||
|
NODE_ENV: appConfig.environment as 'development' | 'production' | 'test',
|
||||||
|
PORT: appConfig.services.api.port,
|
||||||
|
|
||||||
|
// Database
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL ||
|
||||||
|
`postgresql://${appConfig.database.postgres.user || 'postgres'}:${appConfig.database.postgres.password || 'postgres'}@${appConfig.database.postgres.host}:${appConfig.database.postgres.port}/${appConfig.database.postgres.database}`,
|
||||||
|
REDIS_URL: `redis://${appConfig.worker.redis.host}:${appConfig.worker.redis.port}/${appConfig.worker.redis.db}`,
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
JWT_SECRET: appConfig.providers.auth.jwt.secret,
|
||||||
|
JWT_EXPIRES_IN: appConfig.providers.auth.jwt.expiresIn,
|
||||||
|
|
||||||
|
// Scanner
|
||||||
|
SCANNER_CONCURRENCY: appConfig.scanner.concurrency,
|
||||||
|
SCANNER_TIMEOUT: appConfig.scanner.timeout,
|
||||||
|
|
||||||
|
// API Rate Limiting
|
||||||
|
API_RATE_LIMIT: appConfig.services.api.rateLimit.max,
|
||||||
|
API_RATE_WINDOW: appConfig.services.api.rateLimit.windowMs,
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
REPORT_STORAGE_PATH: `${appConfig.providers.storage.local.basePath}/${appConfig.providers.storage.local.reports}`,
|
||||||
|
SCREENSHOT_STORAGE_PATH: `${appConfig.providers.storage.local.basePath}/${appConfig.providers.storage.local.screenshots}`,
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
CORS_ORIGIN: appConfig.services.api.cors.origin,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Config = typeof config;
|
||||||
|
|
||||||
|
// Export the full app config for advanced usage
|
||||||
|
export { appConfig };
|
||||||
64
apps/wcag-ada/api/src/index.ts
Normal file
64
apps/wcag-ada/api/src/index.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { serve } from '@hono/node-server';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { logger as honoLogger } from 'hono/logger';
|
||||||
|
import { compress } from 'hono/compress';
|
||||||
|
import { secureHeaders } from 'hono/secure-headers';
|
||||||
|
import { config, appConfig } from './config';
|
||||||
|
import { logger } from './utils/logger';
|
||||||
|
|
||||||
|
// Import routes
|
||||||
|
import { authRoutes } from './routes/auth';
|
||||||
|
import { websiteRoutes } from './routes/websites';
|
||||||
|
import { scanRoutes } from './routes/scans';
|
||||||
|
import { reportRoutes } from './routes/reports';
|
||||||
|
import { healthRoutes } from './routes/health';
|
||||||
|
|
||||||
|
// Import middleware
|
||||||
|
import { errorHandler } from './middleware/error-handler';
|
||||||
|
import { rateLimiter } from './middleware/rate-limiter';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Global middleware
|
||||||
|
app.use('*', honoLogger());
|
||||||
|
app.use('*', cors({
|
||||||
|
origin: config.CORS_ORIGIN || '*',
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
app.use('*', compress());
|
||||||
|
app.use('*', secureHeaders());
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
app.use('/api/*', rateLimiter());
|
||||||
|
|
||||||
|
// Health check (no auth required)
|
||||||
|
app.route('/health', healthRoutes);
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.route('/api/auth', authRoutes);
|
||||||
|
app.route('/api/websites', websiteRoutes);
|
||||||
|
app.route('/api/scans', scanRoutes);
|
||||||
|
app.route('/api/reports', reportRoutes);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.notFound((c) => {
|
||||||
|
return c.json({ error: 'Not Found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
app.onError(errorHandler);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const port = config.PORT || 3001;
|
||||||
|
|
||||||
|
logger.info(`🚀 WCAG-ADA API Server starting on port ${port}...`);
|
||||||
|
|
||||||
|
serve({
|
||||||
|
fetch: app.fetch,
|
||||||
|
port,
|
||||||
|
}, (info) => {
|
||||||
|
logger.info(`✅ Server is running on http://localhost:${info.port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
69
apps/wcag-ada/api/src/middleware/auth.ts
Normal file
69
apps/wcag-ada/api/src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { Context, Next } from 'hono';
|
||||||
|
import { verify } from 'hono/jwt';
|
||||||
|
import { prisma } from '../utils/prisma';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
export const authenticate = async (c: Context, next: Next) => {
|
||||||
|
try {
|
||||||
|
// Check for Bearer token
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await verify(token, config.JWT_SECRET, 'HS256') as {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
c.set('userId', payload.sub);
|
||||||
|
c.set('userRole', payload.role);
|
||||||
|
c.set('authType', 'jwt');
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
// Invalid JWT, continue to check API key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for API key
|
||||||
|
const apiKey = c.req.header('X-API-Key');
|
||||||
|
if (apiKey) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
apiKey,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
c.set('userId', user.id);
|
||||||
|
c.set('userRole', user.role);
|
||||||
|
c.set('authType', 'apiKey');
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
} catch (error) {
|
||||||
|
return c.json({ error: 'Authentication failed' }, 401);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireRole = (role: string) => {
|
||||||
|
return async (c: Context, next: Next) => {
|
||||||
|
const userRole = c.get('userRole');
|
||||||
|
|
||||||
|
if (!userRole || userRole !== role) {
|
||||||
|
return c.json({ error: 'Insufficient permissions' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
};
|
||||||
59
apps/wcag-ada/api/src/middleware/error-handler.ts
Normal file
59
apps/wcag-ada/api/src/middleware/error-handler.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { Context } from 'hono';
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
export const errorHandler = (err: Error, c: Context) => {
|
||||||
|
logger.error('Error:', err);
|
||||||
|
|
||||||
|
// Handle Hono HTTP exceptions
|
||||||
|
if (err instanceof HTTPException) {
|
||||||
|
return c.json(
|
||||||
|
{ error: err.message },
|
||||||
|
err.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Zod validation errors
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: 'Validation error',
|
||||||
|
details: err.errors.map(e => ({
|
||||||
|
path: e.path.join('.'),
|
||||||
|
message: e.message,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Prisma errors
|
||||||
|
if (err.constructor.name === 'PrismaClientKnownRequestError') {
|
||||||
|
const prismaError = err as any;
|
||||||
|
|
||||||
|
if (prismaError.code === 'P2002') {
|
||||||
|
return c.json(
|
||||||
|
{ error: 'Duplicate entry' },
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prismaError.code === 'P2025') {
|
||||||
|
return c.json(
|
||||||
|
{ error: 'Record not found' },
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error response
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: appConfig.env === 'development' ? err.message : undefined,
|
||||||
|
},
|
||||||
|
500
|
||||||
|
);
|
||||||
|
};
|
||||||
75
apps/wcag-ada/api/src/middleware/rate-limiter.ts
Normal file
75
apps/wcag-ada/api/src/middleware/rate-limiter.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { Context, Next } from 'hono';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { getWorkerConfig } from '@wcag-ada/config';
|
||||||
|
|
||||||
|
const workerConfig = getWorkerConfig();
|
||||||
|
const redis = new Redis({
|
||||||
|
host: workerConfig.redis.host,
|
||||||
|
port: workerConfig.redis.port,
|
||||||
|
password: workerConfig.redis.password,
|
||||||
|
db: workerConfig.redis.db,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RateLimitOptions {
|
||||||
|
limit?: number;
|
||||||
|
window?: number; // in milliseconds
|
||||||
|
keyGenerator?: (c: Context) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rateLimiter = (options: RateLimitOptions = {}) => {
|
||||||
|
const {
|
||||||
|
limit = config.API_RATE_LIMIT,
|
||||||
|
window = config.API_RATE_WINDOW,
|
||||||
|
keyGenerator = (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown';
|
||||||
|
return userId ? `rate:user:${userId}` : `rate:ip:${ip}`;
|
||||||
|
},
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return async (c: Context, next: Next) => {
|
||||||
|
const key = keyGenerator(c);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current count
|
||||||
|
const current = await redis.get(key);
|
||||||
|
const count = current ? parseInt(current) : 0;
|
||||||
|
|
||||||
|
// Check if limit exceeded
|
||||||
|
if (count >= limit) {
|
||||||
|
const ttl = await redis.pttl(key);
|
||||||
|
|
||||||
|
c.header('X-RateLimit-Limit', limit.toString());
|
||||||
|
c.header('X-RateLimit-Remaining', '0');
|
||||||
|
c.header('X-RateLimit-Reset', new Date(Date.now() + ttl).toISOString());
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: 'Rate limit exceeded',
|
||||||
|
retryAfter: Math.ceil(ttl / 1000),
|
||||||
|
},
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment counter
|
||||||
|
const pipeline = redis.pipeline();
|
||||||
|
pipeline.incr(key);
|
||||||
|
pipeline.pexpire(key, window);
|
||||||
|
await pipeline.exec();
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
c.header('X-RateLimit-Limit', limit.toString());
|
||||||
|
c.header('X-RateLimit-Remaining', (limit - count - 1).toString());
|
||||||
|
c.header('X-RateLimit-Reset', new Date(Date.now() + window).toISOString());
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Rate limiter error:', error);
|
||||||
|
// Continue on error - don't block requests due to rate limiter failure
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
177
apps/wcag-ada/api/src/routes/auth.ts
Normal file
177
apps/wcag-ada/api/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { sign } from 'hono/jwt';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { prisma } from '../utils/prisma';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
const authRoutes = new Hono();
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
name: z.string().optional(),
|
||||||
|
company: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register
|
||||||
|
authRoutes.post('/register', zValidator('json', registerSchema), async (c) => {
|
||||||
|
const { email, password, name, company } = c.req.valid('json');
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return c.json({ error: 'User already exists' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
// Create user with API key
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
name,
|
||||||
|
company,
|
||||||
|
apiKey: `wcag_${nanoid(32)}`,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
company: true,
|
||||||
|
apiKey: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
const token = await sign(
|
||||||
|
{
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
config.JWT_SECRET,
|
||||||
|
'HS256'
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
authRoutes.post('/login', zValidator('json', loginSchema), async (c) => {
|
||||||
|
const { email, password } = c.req.valid('json');
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
passwordHash: true,
|
||||||
|
name: true,
|
||||||
|
company: true,
|
||||||
|
apiKey: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
return c.json({ error: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const validPassword = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!validPassword) {
|
||||||
|
return c.json({ error: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
const token = await sign(
|
||||||
|
{
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
config.JWT_SECRET,
|
||||||
|
'HS256'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove passwordHash from response
|
||||||
|
const { passwordHash, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
user: userWithoutPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh API key
|
||||||
|
authRoutes.post('/refresh-api-key', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newApiKey = `wcag_${nanoid(32)}`;
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { apiKey: newApiKey },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
apiKey: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ apiKey: user.apiKey });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
authRoutes.get('/me', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
company: true,
|
||||||
|
role: true,
|
||||||
|
apiKey: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ error: 'User not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { authRoutes };
|
||||||
118
apps/wcag-ada/api/src/routes/health.ts
Normal file
118
apps/wcag-ada/api/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { prisma } from '../utils/prisma';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { getWorkerConfig } from '@wcag-ada/config';
|
||||||
|
|
||||||
|
const healthRoutes = new Hono();
|
||||||
|
|
||||||
|
healthRoutes.get('/', async (c) => {
|
||||||
|
const checks = {
|
||||||
|
api: 'ok',
|
||||||
|
database: 'unknown',
|
||||||
|
redis: 'unknown',
|
||||||
|
queue: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check database
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
checks.database = 'ok';
|
||||||
|
} catch (error) {
|
||||||
|
checks.database = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Redis
|
||||||
|
try {
|
||||||
|
const workerConfig = getWorkerConfig();
|
||||||
|
const redis = new Redis({
|
||||||
|
host: workerConfig.redis.host,
|
||||||
|
port: workerConfig.redis.port,
|
||||||
|
password: workerConfig.redis.password,
|
||||||
|
db: workerConfig.redis.db,
|
||||||
|
});
|
||||||
|
await redis.ping();
|
||||||
|
await redis.quit();
|
||||||
|
checks.redis = 'ok';
|
||||||
|
} catch (error) {
|
||||||
|
checks.redis = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check queue
|
||||||
|
try {
|
||||||
|
const workerConfig = getWorkerConfig();
|
||||||
|
const queue = new Queue(workerConfig.queueName, {
|
||||||
|
connection: new Redis({
|
||||||
|
host: workerConfig.redis.host,
|
||||||
|
port: workerConfig.redis.port,
|
||||||
|
password: workerConfig.redis.password,
|
||||||
|
db: workerConfig.redis.db,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await queue.getJobCounts();
|
||||||
|
await queue.close();
|
||||||
|
checks.queue = 'ok';
|
||||||
|
} catch (error) {
|
||||||
|
checks.queue = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
const allHealthy = Object.values(checks).every(status => status === 'ok');
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
status: allHealthy ? 'healthy' : 'unhealthy',
|
||||||
|
checks,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
allHealthy ? 200 : 503
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
healthRoutes.get('/stats', async (c) => {
|
||||||
|
try {
|
||||||
|
// Get queue stats
|
||||||
|
const workerConfig = getWorkerConfig();
|
||||||
|
const queue = new Queue(workerConfig.queueName, {
|
||||||
|
connection: new Redis({
|
||||||
|
host: workerConfig.redis.host,
|
||||||
|
port: workerConfig.redis.port,
|
||||||
|
password: workerConfig.redis.password,
|
||||||
|
db: workerConfig.redis.db,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [waiting, active, completed, failed] = await Promise.all([
|
||||||
|
queue.getWaitingCount(),
|
||||||
|
queue.getActiveCount(),
|
||||||
|
queue.getCompletedCount(),
|
||||||
|
queue.getFailedCount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const queueStats = { waiting, active, completed, failed };
|
||||||
|
|
||||||
|
const dbStats = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.website.count({ where: { active: true } }),
|
||||||
|
prisma.scanJob.count(),
|
||||||
|
prisma.scanResult.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await queue.close();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
queue: queueStats,
|
||||||
|
database: {
|
||||||
|
users: dbStats[0],
|
||||||
|
websites: dbStats[1],
|
||||||
|
scanJobs: dbStats[2],
|
||||||
|
scanResults: dbStats[3],
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return c.json({ error: 'Failed to get stats' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { healthRoutes };
|
||||||
244
apps/wcag-ada/api/src/routes/reports.ts
Normal file
244
apps/wcag-ada/api/src/routes/reports.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '../utils/prisma';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { generateReport } from '../services/report-generator';
|
||||||
|
import type { ReportPeriod } from '@wcag-ada/shared';
|
||||||
|
|
||||||
|
const reportRoutes = new Hono();
|
||||||
|
|
||||||
|
// Apply authentication to all routes
|
||||||
|
reportRoutes.use('*', authenticate);
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
const generateReportSchema = z.object({
|
||||||
|
websiteId: z.string(),
|
||||||
|
type: z.enum(['COMPLIANCE', 'EXECUTIVE', 'TECHNICAL', 'TREND']),
|
||||||
|
format: z.enum(['PDF', 'HTML', 'JSON', 'CSV']),
|
||||||
|
period: z.object({
|
||||||
|
start: z.string().transform(s => new Date(s)),
|
||||||
|
end: z.string().transform(s => new Date(s)),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate a new report
|
||||||
|
reportRoutes.post('/generate', zValidator('json', generateReportSchema), async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const { websiteId, type, format, period } = c.req.valid('json');
|
||||||
|
|
||||||
|
// Check website ownership
|
||||||
|
const website = await prisma.website.findFirst({
|
||||||
|
where: {
|
||||||
|
id: websiteId,
|
||||||
|
userId,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!website) {
|
||||||
|
return c.json({ error: 'Website not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get scan results for the period
|
||||||
|
const scanResults = await prisma.scanResult.findMany({
|
||||||
|
where: {
|
||||||
|
websiteId,
|
||||||
|
createdAt: {
|
||||||
|
gte: period.start,
|
||||||
|
lte: period.end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (scanResults.length === 0) {
|
||||||
|
return c.json({ error: 'No scan data available for the specified period' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate report data
|
||||||
|
const reportData = await generateReport({
|
||||||
|
website,
|
||||||
|
scanResults,
|
||||||
|
type,
|
||||||
|
format,
|
||||||
|
period,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save report
|
||||||
|
const report = await prisma.report.create({
|
||||||
|
data: {
|
||||||
|
websiteId,
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
format,
|
||||||
|
period: period as any,
|
||||||
|
summary: reportData.summary as any,
|
||||||
|
data: reportData.data as any,
|
||||||
|
fileUrl: reportData.fileUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
report: {
|
||||||
|
id: report.id,
|
||||||
|
type: report.type,
|
||||||
|
format: report.format,
|
||||||
|
period: report.period,
|
||||||
|
fileUrl: report.fileUrl,
|
||||||
|
generatedAt: report.generatedAt,
|
||||||
|
},
|
||||||
|
}, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user's reports
|
||||||
|
reportRoutes.get('/', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const { page = '1', limit = '20', websiteId, type } = c.req.query();
|
||||||
|
|
||||||
|
const pageNum = parseInt(page);
|
||||||
|
const limitNum = parseInt(limit);
|
||||||
|
const skip = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
userId,
|
||||||
|
...(websiteId && { websiteId }),
|
||||||
|
...(type && { type }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [reports, total] = await Promise.all([
|
||||||
|
prisma.report.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limitNum,
|
||||||
|
orderBy: { generatedAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
website: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.report.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
reports,
|
||||||
|
pagination: {
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limitNum),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get report by ID
|
||||||
|
reportRoutes.get('/:id', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const reportId = c.req.param('id');
|
||||||
|
|
||||||
|
const report = await prisma.report.findFirst({
|
||||||
|
where: {
|
||||||
|
id: reportId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
website: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return c.json({ error: 'Report not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(report);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download report file
|
||||||
|
reportRoutes.get('/:id/download', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const reportId = c.req.param('id');
|
||||||
|
|
||||||
|
const report = await prisma.report.findFirst({
|
||||||
|
where: {
|
||||||
|
id: reportId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!report || !report.fileUrl) {
|
||||||
|
return c.json({ error: 'Report file not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, this would redirect to a signed URL or serve from storage
|
||||||
|
// For now, we'll just return the file URL
|
||||||
|
return c.json({ downloadUrl: report.fileUrl });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get compliance trends
|
||||||
|
reportRoutes.get('/trends/:websiteId', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const websiteId = c.req.param('websiteId');
|
||||||
|
const { days = '30' } = c.req.query();
|
||||||
|
|
||||||
|
// Check website ownership
|
||||||
|
const website = await prisma.website.findFirst({
|
||||||
|
where: {
|
||||||
|
id: websiteId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!website) {
|
||||||
|
return c.json({ error: 'Website not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysNum = parseInt(days);
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - daysNum);
|
||||||
|
|
||||||
|
const scanResults = await prisma.scanResult.findMany({
|
||||||
|
where: {
|
||||||
|
websiteId,
|
||||||
|
createdAt: {
|
||||||
|
gte: startDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: {
|
||||||
|
createdAt: true,
|
||||||
|
summary: true,
|
||||||
|
wcagCompliance: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const trends = scanResults.map(result => ({
|
||||||
|
date: result.createdAt,
|
||||||
|
score: (result.summary as any).score,
|
||||||
|
violationCount: (result.summary as any).violationCount,
|
||||||
|
passCount: (result.summary as any).passCount,
|
||||||
|
criticalIssues: (result.summary as any).criticalIssues,
|
||||||
|
seriousIssues: (result.summary as any).seriousIssues,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
websiteId,
|
||||||
|
period: {
|
||||||
|
start: startDate,
|
||||||
|
end: new Date(),
|
||||||
|
},
|
||||||
|
trends,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export { reportRoutes };
|
||||||
283
apps/wcag-ada/api/src/routes/scans.ts
Normal file
283
apps/wcag-ada/api/src/routes/scans.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { prisma } from '../utils/prisma';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { getWorkerConfig } from '@wcag-ada/config';
|
||||||
|
import type { AccessibilityScanOptions } from '@wcag-ada/shared';
|
||||||
|
|
||||||
|
const scanRoutes = new Hono();
|
||||||
|
|
||||||
|
// Get worker config for queue connection
|
||||||
|
const workerConfig = getWorkerConfig();
|
||||||
|
|
||||||
|
// Create queue connection
|
||||||
|
const scanQueue = new Queue('accessibility-scans', {
|
||||||
|
connection: new Redis({
|
||||||
|
host: workerConfig.redis.host,
|
||||||
|
port: workerConfig.redis.port,
|
||||||
|
password: workerConfig.redis.password,
|
||||||
|
db: workerConfig.redis.db,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply authentication to all routes
|
||||||
|
scanRoutes.use('*', authenticate);
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
const createScanSchema = z.object({
|
||||||
|
websiteId: z.string(),
|
||||||
|
url: z.string().url().optional(),
|
||||||
|
options: z.object({
|
||||||
|
wcagLevel: z.object({
|
||||||
|
level: z.enum(['A', 'AA', 'AAA']),
|
||||||
|
version: z.enum(['2.0', '2.1', '2.2']),
|
||||||
|
}).optional(),
|
||||||
|
viewport: z.object({
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
deviceScaleFactor: z.number().optional(),
|
||||||
|
isMobile: z.boolean().optional(),
|
||||||
|
}).optional(),
|
||||||
|
includeScreenshots: z.boolean().optional(),
|
||||||
|
excludeSelectors: z.array(z.string()).optional(),
|
||||||
|
waitForSelector: z.string().optional(),
|
||||||
|
timeout: z.number().optional(),
|
||||||
|
}).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start a new scan
|
||||||
|
scanRoutes.post('/', zValidator('json', createScanSchema), async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const { websiteId, url, options } = c.req.valid('json');
|
||||||
|
|
||||||
|
// Get website
|
||||||
|
const website = await prisma.website.findFirst({
|
||||||
|
where: {
|
||||||
|
id: websiteId,
|
||||||
|
userId,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!website) {
|
||||||
|
return c.json({ error: 'Website not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge options with website defaults
|
||||||
|
const scanOptions: AccessibilityScanOptions = {
|
||||||
|
url: url || website.url,
|
||||||
|
...website.scanOptions as any,
|
||||||
|
...options,
|
||||||
|
authenticate: website.authConfig as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create scan job
|
||||||
|
const job = await prisma.scanJob.create({
|
||||||
|
data: {
|
||||||
|
websiteId,
|
||||||
|
userId,
|
||||||
|
url: scanOptions.url,
|
||||||
|
options: scanOptions as any,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue the scan
|
||||||
|
await scanQueue.add('scan', {
|
||||||
|
jobId: job.id,
|
||||||
|
websiteId,
|
||||||
|
userId,
|
||||||
|
options: scanOptions,
|
||||||
|
}, {
|
||||||
|
jobId: job.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
job: {
|
||||||
|
id: job.id,
|
||||||
|
status: job.status,
|
||||||
|
url: job.url,
|
||||||
|
scheduledAt: job.scheduledAt,
|
||||||
|
},
|
||||||
|
message: 'Scan queued successfully',
|
||||||
|
}, 202);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get scan status
|
||||||
|
scanRoutes.get('/:id', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const scanId = c.req.param('id');
|
||||||
|
|
||||||
|
const job = await prisma.scanJob.findFirst({
|
||||||
|
where: {
|
||||||
|
id: scanId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
result: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
summary: true,
|
||||||
|
wcagCompliance: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return c.json({ error: 'Scan not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(job);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get scan result
|
||||||
|
scanRoutes.get('/:id/result', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const scanId = c.req.param('id');
|
||||||
|
|
||||||
|
const result = await prisma.scanResult.findFirst({
|
||||||
|
where: {
|
||||||
|
jobId: scanId,
|
||||||
|
job: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return c.json({ error: 'Scan result not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get scan violations
|
||||||
|
scanRoutes.get('/:id/violations', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const scanId = c.req.param('id');
|
||||||
|
const { impact, tag } = c.req.query();
|
||||||
|
|
||||||
|
const result = await prisma.scanResult.findFirst({
|
||||||
|
where: {
|
||||||
|
jobId: scanId,
|
||||||
|
job: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
violations: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return c.json({ error: 'Scan result not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let violations = result.violations as any[];
|
||||||
|
|
||||||
|
// Filter by impact if provided
|
||||||
|
if (impact) {
|
||||||
|
violations = violations.filter(v => v.impact === impact);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by tag if provided
|
||||||
|
if (tag) {
|
||||||
|
violations = violations.filter(v => v.tags.includes(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ violations });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel a pending scan
|
||||||
|
scanRoutes.delete('/:id', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const scanId = c.req.param('id');
|
||||||
|
|
||||||
|
const job = await prisma.scanJob.findFirst({
|
||||||
|
where: {
|
||||||
|
id: scanId,
|
||||||
|
userId,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return c.json({ error: 'Scan not found or cannot be cancelled' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update job status
|
||||||
|
await prisma.scanJob.update({
|
||||||
|
where: { id: scanId },
|
||||||
|
data: {
|
||||||
|
status: 'FAILED',
|
||||||
|
error: 'Cancelled by user',
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from queue if possible
|
||||||
|
const queueJob = await scanQueue.getJob(scanId);
|
||||||
|
if (queueJob) {
|
||||||
|
await queueJob.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ message: 'Scan cancelled successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user's scan history
|
||||||
|
scanRoutes.get('/', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const { page = '1', limit = '20', status, websiteId } = c.req.query();
|
||||||
|
|
||||||
|
const pageNum = parseInt(page);
|
||||||
|
const limitNum = parseInt(limit);
|
||||||
|
const skip = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
userId,
|
||||||
|
...(status && { status }),
|
||||||
|
...(websiteId && { websiteId }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [jobs, total] = await Promise.all([
|
||||||
|
prisma.scanJob.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limitNum,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
website: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
summary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.scanJob.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
scans: jobs,
|
||||||
|
pagination: {
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limitNum),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export { scanRoutes };
|
||||||
245
apps/wcag-ada/api/src/routes/websites.ts
Normal file
245
apps/wcag-ada/api/src/routes/websites.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '../utils/prisma';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import type { Website, ScanSchedule } from '@wcag-ada/shared';
|
||||||
|
|
||||||
|
const websiteRoutes = new Hono();
|
||||||
|
|
||||||
|
// Apply authentication to all routes
|
||||||
|
websiteRoutes.use('*', authenticate);
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
const createWebsiteSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
url: z.string().url(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
scanSchedule: z.object({
|
||||||
|
frequency: z.enum(['manual', 'hourly', 'daily', 'weekly', 'monthly']),
|
||||||
|
dayOfWeek: z.number().min(0).max(6).optional(),
|
||||||
|
dayOfMonth: z.number().min(1).max(31).optional(),
|
||||||
|
hour: z.number().min(0).max(23).optional(),
|
||||||
|
timezone: z.string().optional(),
|
||||||
|
}).optional(),
|
||||||
|
authConfig: z.any().optional(),
|
||||||
|
scanOptions: z.any().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateWebsiteSchema = createWebsiteSchema.partial();
|
||||||
|
|
||||||
|
// List websites
|
||||||
|
websiteRoutes.get('/', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const { page = '1', limit = '20', search } = c.req.query();
|
||||||
|
|
||||||
|
const pageNum = parseInt(page);
|
||||||
|
const limitNum = parseInt(limit);
|
||||||
|
const skip = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
userId,
|
||||||
|
...(search && {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ url: { contains: search, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [websites, total] = await Promise.all([
|
||||||
|
prisma.website.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limitNum,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { scanResults: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.website.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
websites,
|
||||||
|
pagination: {
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limitNum),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get website by ID
|
||||||
|
websiteRoutes.get('/:id', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const websiteId = c.req.param('id');
|
||||||
|
|
||||||
|
const website = await prisma.website.findFirst({
|
||||||
|
where: {
|
||||||
|
id: websiteId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
scanResults: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
scanResults: true,
|
||||||
|
scanJobs: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!website) {
|
||||||
|
return c.json({ error: 'Website not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(website);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create website
|
||||||
|
websiteRoutes.post('/', zValidator('json', createWebsiteSchema), async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
// Check if website already exists for this user
|
||||||
|
const existing = await prisma.website.findFirst({
|
||||||
|
where: {
|
||||||
|
url: data.url,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return c.json({ error: 'Website already exists' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const website = await prisma.website.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
userId,
|
||||||
|
scanSchedule: data.scanSchedule ? data.scanSchedule : undefined,
|
||||||
|
authConfig: data.authConfig ? data.authConfig : undefined,
|
||||||
|
scanOptions: data.scanOptions ? data.scanOptions : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(website, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update website
|
||||||
|
websiteRoutes.patch('/:id', zValidator('json', updateWebsiteSchema), async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const websiteId = c.req.param('id');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
const existing = await prisma.website.findFirst({
|
||||||
|
where: {
|
||||||
|
id: websiteId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'Website not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const website = await prisma.website.update({
|
||||||
|
where: { id: websiteId },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
scanSchedule: data.scanSchedule !== undefined ? data.scanSchedule : undefined,
|
||||||
|
authConfig: data.authConfig !== undefined ? data.authConfig : undefined,
|
||||||
|
scanOptions: data.scanOptions !== undefined ? data.scanOptions : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(website);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete website
|
||||||
|
websiteRoutes.delete('/:id', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const websiteId = c.req.param('id');
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
const existing = await prisma.website.findFirst({
|
||||||
|
where: {
|
||||||
|
id: websiteId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'Website not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete by setting active to false
|
||||||
|
await prisma.website.update({
|
||||||
|
where: { id: websiteId },
|
||||||
|
data: { active: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ message: 'Website deleted successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get website scan history
|
||||||
|
websiteRoutes.get('/:id/scans', async (c) => {
|
||||||
|
const userId = c.get('userId');
|
||||||
|
const websiteId = c.req.param('id');
|
||||||
|
const { page = '1', limit = '20' } = c.req.query();
|
||||||
|
|
||||||
|
const pageNum = parseInt(page);
|
||||||
|
const limitNum = parseInt(limit);
|
||||||
|
const skip = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
const website = await prisma.website.findFirst({
|
||||||
|
where: {
|
||||||
|
id: websiteId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!website) {
|
||||||
|
return c.json({ error: 'Website not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [scans, total] = await Promise.all([
|
||||||
|
prisma.scanResult.findMany({
|
||||||
|
where: { websiteId },
|
||||||
|
skip,
|
||||||
|
take: limitNum,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
url: true,
|
||||||
|
scanDuration: true,
|
||||||
|
summary: true,
|
||||||
|
wcagCompliance: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.scanResult.count({ where: { websiteId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
scans,
|
||||||
|
pagination: {
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limitNum),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export { websiteRoutes };
|
||||||
271
apps/wcag-ada/api/src/services/report-generator.ts
Normal file
271
apps/wcag-ada/api/src/services/report-generator.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
import type { Website, ScanResult, Report } from '@prisma/client';
|
||||||
|
import type { ReportPeriod, ReportSummary } from '@wcag-ada/shared';
|
||||||
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { getStorageConfig } from '@wcag-ada/config';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
interface GenerateReportOptions {
|
||||||
|
website: Website;
|
||||||
|
scanResults: ScanResult[];
|
||||||
|
type: 'COMPLIANCE' | 'EXECUTIVE' | 'TECHNICAL' | 'TREND';
|
||||||
|
format: 'PDF' | 'HTML' | 'JSON' | 'CSV';
|
||||||
|
period: ReportPeriod;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerateReportResult {
|
||||||
|
summary: ReportSummary;
|
||||||
|
data: any;
|
||||||
|
fileUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateReport(
|
||||||
|
options: GenerateReportOptions
|
||||||
|
): Promise<GenerateReportResult> {
|
||||||
|
const { website, scanResults, type, format, period } = options;
|
||||||
|
|
||||||
|
// Calculate summary statistics
|
||||||
|
const summary = calculateReportSummary(scanResults, period);
|
||||||
|
|
||||||
|
// Generate report data based on type
|
||||||
|
let data: any;
|
||||||
|
switch (type) {
|
||||||
|
case 'COMPLIANCE':
|
||||||
|
data = generateComplianceData(website, scanResults, summary);
|
||||||
|
break;
|
||||||
|
case 'EXECUTIVE':
|
||||||
|
data = generateExecutiveData(website, scanResults, summary);
|
||||||
|
break;
|
||||||
|
case 'TECHNICAL':
|
||||||
|
data = generateTechnicalData(website, scanResults, summary);
|
||||||
|
break;
|
||||||
|
case 'TREND':
|
||||||
|
data = generateTrendData(website, scanResults, summary);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate file if not JSON format
|
||||||
|
let fileUrl: string | undefined;
|
||||||
|
if (format !== 'JSON') {
|
||||||
|
fileUrl = await generateReportFile(data, format, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
data,
|
||||||
|
fileUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateReportSummary(
|
||||||
|
scanResults: ScanResult[],
|
||||||
|
period: ReportPeriod
|
||||||
|
): ReportSummary {
|
||||||
|
if (scanResults.length === 0) {
|
||||||
|
return {
|
||||||
|
averageScore: 0,
|
||||||
|
totalScans: 0,
|
||||||
|
improvementRate: 0,
|
||||||
|
criticalIssuesFixed: 0,
|
||||||
|
newIssuesFound: 0,
|
||||||
|
complianceLevel: { level: 'AA', version: '2.1' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate average score
|
||||||
|
const scores = scanResults.map(r => (r.summary as any).score || 0);
|
||||||
|
const averageScore = scores.reduce((a, b) => a + b, 0) / scores.length;
|
||||||
|
|
||||||
|
// Calculate improvement rate
|
||||||
|
const firstScore = scores[0];
|
||||||
|
const lastScore = scores[scores.length - 1];
|
||||||
|
const improvementRate = firstScore > 0 ? ((lastScore - firstScore) / firstScore) * 100 : 0;
|
||||||
|
|
||||||
|
// Count issues fixed and new issues
|
||||||
|
let criticalIssuesFixed = 0;
|
||||||
|
let newIssuesFound = 0;
|
||||||
|
|
||||||
|
if (scanResults.length >= 2) {
|
||||||
|
const firstScan = scanResults[0];
|
||||||
|
const lastScan = scanResults[scanResults.length - 1];
|
||||||
|
|
||||||
|
const firstCritical = (firstScan.summary as any).criticalIssues || 0;
|
||||||
|
const lastCritical = (lastScan.summary as any).criticalIssues || 0;
|
||||||
|
|
||||||
|
criticalIssuesFixed = Math.max(0, firstCritical - lastCritical);
|
||||||
|
newIssuesFound = Math.max(0, lastCritical - firstCritical);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get compliance level from last scan
|
||||||
|
const lastScan = scanResults[scanResults.length - 1];
|
||||||
|
const complianceLevel = (lastScan.wcagCompliance as any)?.level || { level: 'AA', version: '2.1' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
averageScore: Math.round(averageScore * 100) / 100,
|
||||||
|
totalScans: scanResults.length,
|
||||||
|
improvementRate: Math.round(improvementRate * 100) / 100,
|
||||||
|
criticalIssuesFixed,
|
||||||
|
newIssuesFound,
|
||||||
|
complianceLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateComplianceData(
|
||||||
|
website: Website,
|
||||||
|
scanResults: ScanResult[],
|
||||||
|
summary: ReportSummary
|
||||||
|
): any {
|
||||||
|
return {
|
||||||
|
website: {
|
||||||
|
name: website.name,
|
||||||
|
url: website.url,
|
||||||
|
},
|
||||||
|
summary,
|
||||||
|
latestScan: scanResults[scanResults.length - 1],
|
||||||
|
complianceHistory: scanResults.map(r => ({
|
||||||
|
date: r.createdAt,
|
||||||
|
score: (r.summary as any).score,
|
||||||
|
compliant: (r.wcagCompliance as any).isCompliant,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateExecutiveData(
|
||||||
|
website: Website,
|
||||||
|
scanResults: ScanResult[],
|
||||||
|
summary: ReportSummary
|
||||||
|
): any {
|
||||||
|
return {
|
||||||
|
website: {
|
||||||
|
name: website.name,
|
||||||
|
url: website.url,
|
||||||
|
},
|
||||||
|
summary,
|
||||||
|
keyMetrics: {
|
||||||
|
currentScore: (scanResults[scanResults.length - 1].summary as any).score,
|
||||||
|
improvement: summary.improvementRate,
|
||||||
|
totalIssues: (scanResults[scanResults.length - 1].summary as any).violationCount,
|
||||||
|
criticalIssues: (scanResults[scanResults.length - 1].summary as any).criticalIssues,
|
||||||
|
},
|
||||||
|
recommendations: generateRecommendations(scanResults[scanResults.length - 1]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTechnicalData(
|
||||||
|
website: Website,
|
||||||
|
scanResults: ScanResult[],
|
||||||
|
summary: ReportSummary
|
||||||
|
): any {
|
||||||
|
const latestScan = scanResults[scanResults.length - 1];
|
||||||
|
return {
|
||||||
|
website: {
|
||||||
|
name: website.name,
|
||||||
|
url: website.url,
|
||||||
|
},
|
||||||
|
summary,
|
||||||
|
violations: latestScan.violations,
|
||||||
|
topIssues: getTopIssues(latestScan.violations as any[]),
|
||||||
|
fixPriority: generateFixPriority(latestScan.violations as any[]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTrendData(
|
||||||
|
website: Website,
|
||||||
|
scanResults: ScanResult[],
|
||||||
|
summary: ReportSummary
|
||||||
|
): any {
|
||||||
|
return {
|
||||||
|
website: {
|
||||||
|
name: website.name,
|
||||||
|
url: website.url,
|
||||||
|
},
|
||||||
|
summary,
|
||||||
|
trends: scanResults.map(r => ({
|
||||||
|
date: r.createdAt,
|
||||||
|
score: (r.summary as any).score,
|
||||||
|
violations: (r.summary as any).violationCount,
|
||||||
|
critical: (r.summary as any).criticalIssues,
|
||||||
|
serious: (r.summary as any).seriousIssues,
|
||||||
|
moderate: (r.summary as any).moderateIssues,
|
||||||
|
minor: (r.summary as any).minorIssues,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRecommendations(scanResult: ScanResult): string[] {
|
||||||
|
const violations = scanResult.violations as any[];
|
||||||
|
const recommendations: string[] = [];
|
||||||
|
|
||||||
|
// Group violations by type and generate recommendations
|
||||||
|
const criticalViolations = violations.filter(v => v.impact === 'critical');
|
||||||
|
if (criticalViolations.length > 0) {
|
||||||
|
recommendations.push('Address critical accessibility violations immediately to reduce legal risk');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add more intelligent recommendations based on violation patterns
|
||||||
|
|
||||||
|
return recommendations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopIssues(violations: any[]): any[] {
|
||||||
|
// Sort by impact and number of occurrences
|
||||||
|
return violations
|
||||||
|
.sort((a, b) => {
|
||||||
|
const impactOrder = { critical: 4, serious: 3, moderate: 2, minor: 1 };
|
||||||
|
const aScore = impactOrder[a.impact] * a.nodes.length;
|
||||||
|
const bScore = impactOrder[b.impact] * b.nodes.length;
|
||||||
|
return bScore - aScore;
|
||||||
|
})
|
||||||
|
.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFixPriority(violations: any[]): any[] {
|
||||||
|
// Generate prioritized fix list
|
||||||
|
return violations
|
||||||
|
.map(v => ({
|
||||||
|
id: v.id,
|
||||||
|
impact: v.impact,
|
||||||
|
occurrences: v.nodes.length,
|
||||||
|
estimatedTime: estimateFixTime(v),
|
||||||
|
priority: calculatePriority(v),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.priority - a.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateFixTime(violation: any): number {
|
||||||
|
const baseTime = {
|
||||||
|
critical: 30,
|
||||||
|
serious: 20,
|
||||||
|
moderate: 15,
|
||||||
|
minor: 10,
|
||||||
|
};
|
||||||
|
return (baseTime[violation.impact] || 15) * violation.nodes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePriority(violation: any): number {
|
||||||
|
const impactScore = { critical: 100, serious: 75, moderate: 50, minor: 25 };
|
||||||
|
return (impactScore[violation.impact] || 50) * Math.log(violation.nodes.length + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateReportFile(
|
||||||
|
data: any,
|
||||||
|
format: 'PDF' | 'HTML' | 'CSV',
|
||||||
|
type: string
|
||||||
|
): Promise<string> {
|
||||||
|
const storageConfig = getStorageConfig();
|
||||||
|
const reportPath = join(storageConfig.local.basePath, storageConfig.local.reports);
|
||||||
|
|
||||||
|
// Create report directory if it doesn't exist
|
||||||
|
await mkdir(reportPath, { recursive: true });
|
||||||
|
|
||||||
|
const filename = `${type.toLowerCase()}_${nanoid()}.${format.toLowerCase()}`;
|
||||||
|
const filepath = join(reportPath, filename);
|
||||||
|
|
||||||
|
// For now, just save as JSON
|
||||||
|
// In production, you would generate actual PDF/HTML/CSV files
|
||||||
|
await writeFile(filepath, JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
// Return relative URL (in production, this would be a CDN URL)
|
||||||
|
return `/reports/${filename}`;
|
||||||
|
}
|
||||||
12
apps/wcag-ada/api/src/utils/prisma.ts
Normal file
12
apps/wcag-ada/api/src/utils/prisma.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
|
||||||
|
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ||
|
||||||
|
new PrismaClient({
|
||||||
|
log: appConfig.environment === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (appConfig.environment !== 'production') globalForPrisma.prisma = prisma;
|
||||||
16
apps/wcag-ada/api/tsconfig.json
Normal file
16
apps/wcag-ada/api/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"composite": true,
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../shared" },
|
||||||
|
{ "path": "../scanner" }
|
||||||
|
]
|
||||||
|
}
|
||||||
181
apps/wcag-ada/config/README.md
Normal file
181
apps/wcag-ada/config/README.md
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
# WCAG-ADA Configuration
|
||||||
|
|
||||||
|
Centralized configuration management for the WCAG-ADA compliance platform, built on top of the core configuration system.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This configuration system provides:
|
||||||
|
- Type-safe configuration with Zod schemas
|
||||||
|
- Multi-source configuration (JSON files + environment variables)
|
||||||
|
- Environment-specific overrides
|
||||||
|
- Service-specific configurations
|
||||||
|
- Feature flags management
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { initializeWcagConfig, getWcagConfig } from '@wcag-ada/config';
|
||||||
|
|
||||||
|
// Initialize configuration (do this once at app startup)
|
||||||
|
const config = initializeWcagConfig('api'); // 'api' | 'dashboard' | 'worker'
|
||||||
|
|
||||||
|
// Access configuration values
|
||||||
|
console.log(config.services.api.port); // 3001
|
||||||
|
console.log(config.scanner.concurrency); // 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helper Functions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
getScannerConfig,
|
||||||
|
getWorkerConfig,
|
||||||
|
isFeatureEnabled,
|
||||||
|
getServiceConfig
|
||||||
|
} from '@wcag-ada/config';
|
||||||
|
|
||||||
|
// Get specific configurations
|
||||||
|
const scannerConfig = getScannerConfig();
|
||||||
|
const workerConfig = getWorkerConfig();
|
||||||
|
|
||||||
|
// Check feature flags
|
||||||
|
if (isFeatureEnabled('screenshots')) {
|
||||||
|
// Screenshot feature is enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFeatureEnabled('reports.pdf')) {
|
||||||
|
// PDF reports are enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get service configuration
|
||||||
|
const apiConfig = getServiceConfig('api');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Structure
|
||||||
|
|
||||||
|
### Scanner Configuration
|
||||||
|
- `concurrency`: Number of concurrent scans (1-10)
|
||||||
|
- `timeout`: Maximum scan duration
|
||||||
|
- `headless`: Run browser in headless mode
|
||||||
|
- `blockResources`: Block images/fonts for faster scans
|
||||||
|
- `viewport`: Default viewport dimensions
|
||||||
|
|
||||||
|
### Worker Configuration
|
||||||
|
- `concurrency`: Number of concurrent jobs
|
||||||
|
- `queueName`: BullMQ queue name
|
||||||
|
- `redis`: Redis connection settings
|
||||||
|
- `jobs`: Job-specific configurations
|
||||||
|
- `scheduler`: Scheduled job settings
|
||||||
|
|
||||||
|
### Features Configuration
|
||||||
|
- `screenshots`: Screenshot capture settings
|
||||||
|
- `customRules`: Custom accessibility rules
|
||||||
|
- `multiPage`: Multi-page scanning
|
||||||
|
- `reports`: Report generation features
|
||||||
|
- `integrations`: External service integrations
|
||||||
|
- `enterprise`: Enterprise features (SSO, white-label)
|
||||||
|
|
||||||
|
### Providers Configuration
|
||||||
|
- `storage`: File storage (local, S3, GCS, Azure)
|
||||||
|
- `email`: Email providers (SMTP, SendGrid, SES)
|
||||||
|
- `auth`: Authentication settings (JWT, OAuth)
|
||||||
|
- `analytics`: Analytics providers
|
||||||
|
- `cdn`: CDN configuration
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
The system supports environment variable overrides with the `WCAG_` prefix:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scanner settings
|
||||||
|
WCAG_SCANNER_CONCURRENCY=5
|
||||||
|
WCAG_SCANNER_TIMEOUT=180000
|
||||||
|
WCAG_SCANNER_HEADLESS=true
|
||||||
|
|
||||||
|
# Worker settings
|
||||||
|
WCAG_WORKER_CONCURRENCY=3
|
||||||
|
WCAG_WORKER_REDIS_HOST=redis.example.com
|
||||||
|
WCAG_WORKER_REDIS_PORT=6379
|
||||||
|
|
||||||
|
# Service ports
|
||||||
|
WCAG_SERVICES_API_PORT=3001
|
||||||
|
WCAG_SERVICES_DASHBOARD_PORT=3000
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
WCAG_PROVIDERS_STORAGE_TYPE=s3
|
||||||
|
WCAG_PROVIDERS_STORAGE_S3_BUCKET=wcag-ada-storage
|
||||||
|
WCAG_PROVIDERS_STORAGE_S3_REGION=us-east-1
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
WCAG_PROVIDERS_AUTH_JWT_SECRET=your-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
Configuration files are loaded in this order (later files override earlier ones):
|
||||||
|
1. `config/default.json` - Base configuration
|
||||||
|
2. `config/{environment}.json` - Environment-specific (development, production, test)
|
||||||
|
3. Environment variables - Highest priority
|
||||||
|
|
||||||
|
## Service-Specific Configuration
|
||||||
|
|
||||||
|
Each service can have its own configuration:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// API Service
|
||||||
|
initializeWcagConfig('api');
|
||||||
|
// Uses services.api configuration
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
initializeWcagConfig('dashboard');
|
||||||
|
// Uses services.dashboard configuration
|
||||||
|
|
||||||
|
// Worker
|
||||||
|
initializeWcagConfig('worker');
|
||||||
|
// Uses services.worker configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Configuration
|
||||||
|
|
||||||
|
1. Add schema definition in `src/schemas/`
|
||||||
|
2. Update `wcag-app.schema.ts` to include new schema
|
||||||
|
3. Add default values in `config/default.json`
|
||||||
|
4. Add environment-specific overrides as needed
|
||||||
|
5. Create helper functions in `src/index.ts` for easy access
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Check Subscription Limits
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getSubscriptionConfig } from '@wcag-ada/config';
|
||||||
|
|
||||||
|
const starterLimits = getSubscriptionConfig('starter');
|
||||||
|
console.log(starterLimits.websites); // 5
|
||||||
|
console.log(starterLimits.scansPerMonth); // 500
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Compliance Settings
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getComplianceConfig } from '@wcag-ada/config';
|
||||||
|
|
||||||
|
const compliance = getComplianceConfig();
|
||||||
|
console.log(compliance.defaultLevel); // { standard: 'WCAG21', level: 'AA' }
|
||||||
|
console.log(compliance.criticalCriteria); // ['1.1.1', '1.3.1', ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getStorageConfig } from '@wcag-ada/config';
|
||||||
|
|
||||||
|
const storage = getStorageConfig();
|
||||||
|
if (storage.type === 's3') {
|
||||||
|
// Use S3 storage
|
||||||
|
} else {
|
||||||
|
// Use local storage
|
||||||
|
}
|
||||||
|
```
|
||||||
117
apps/wcag-ada/config/config/default.json
Normal file
117
apps/wcag-ada/config/config/default.json
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
{
|
||||||
|
"appName": "wcag-ada",
|
||||||
|
"environment": "default",
|
||||||
|
|
||||||
|
"log": {
|
||||||
|
"level": "info",
|
||||||
|
"format": "json",
|
||||||
|
"pretty": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"database": {
|
||||||
|
"postgres": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 5432,
|
||||||
|
"database": "wcag_ada",
|
||||||
|
"connectionLimit": 10
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 6379,
|
||||||
|
"db": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"scanner": {
|
||||||
|
"concurrency": 2,
|
||||||
|
"timeout": 120000,
|
||||||
|
"pageLoadTimeout": 30000,
|
||||||
|
"headless": true,
|
||||||
|
"blockResources": true,
|
||||||
|
"viewport": {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"deviceScaleFactor": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"worker": {
|
||||||
|
"enabled": true,
|
||||||
|
"concurrency": 2,
|
||||||
|
"queueName": "accessibility-scans",
|
||||||
|
"redis": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 6379,
|
||||||
|
"db": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"features": {
|
||||||
|
"screenshots": {
|
||||||
|
"enabled": true,
|
||||||
|
"quality": 80
|
||||||
|
},
|
||||||
|
"customRules": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"pdf": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"providers": {
|
||||||
|
"storage": {
|
||||||
|
"type": "local",
|
||||||
|
"local": {
|
||||||
|
"basePath": "/tmp/wcag-ada"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "smtp"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"jwt": {
|
||||||
|
"expiresIn": "7d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"services": {
|
||||||
|
"api": {
|
||||||
|
"name": "wcag-api",
|
||||||
|
"port": 3001,
|
||||||
|
"cors": {
|
||||||
|
"enabled": true,
|
||||||
|
"origin": "*"
|
||||||
|
},
|
||||||
|
"rateLimit": {
|
||||||
|
"enabled": true,
|
||||||
|
"windowMs": 900000,
|
||||||
|
"max": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"name": "wcag-dashboard",
|
||||||
|
"port": 3000,
|
||||||
|
"apiUrl": "http://localhost:3001"
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"name": "wcag-worker",
|
||||||
|
"port": 3002
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"compliance": {
|
||||||
|
"defaultLevel": {
|
||||||
|
"standard": "WCAG21",
|
||||||
|
"level": "AA"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"subscriptions": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/wcag-ada/config/config/development.json
Normal file
51
apps/wcag-ada/config/config/development.json
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
"environment": "development",
|
||||||
|
|
||||||
|
"log": {
|
||||||
|
"level": "debug",
|
||||||
|
"pretty": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"database": {
|
||||||
|
"postgres": {
|
||||||
|
"host": "localhost",
|
||||||
|
"database": "wcag_ada_dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"scanner": {
|
||||||
|
"headless": false,
|
||||||
|
"timeout": 300000
|
||||||
|
},
|
||||||
|
|
||||||
|
"worker": {
|
||||||
|
"concurrency": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
"features": {
|
||||||
|
"reports": {
|
||||||
|
"pdf": {
|
||||||
|
"watermark": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enterprise": {
|
||||||
|
"audit": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"providers": {
|
||||||
|
"auth": {
|
||||||
|
"jwt": {
|
||||||
|
"secret": "dev-secret-change-me"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"services": {
|
||||||
|
"dashboard": {
|
||||||
|
"apiUrl": "http://localhost:3001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
apps/wcag-ada/config/config/production.json
Normal file
64
apps/wcag-ada/config/config/production.json
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
{
|
||||||
|
"environment": "production",
|
||||||
|
|
||||||
|
"log": {
|
||||||
|
"level": "warn",
|
||||||
|
"pretty": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"scanner": {
|
||||||
|
"concurrency": 5,
|
||||||
|
"headless": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"worker": {
|
||||||
|
"concurrency": 4,
|
||||||
|
"redis": {
|
||||||
|
"maxRetriesPerRequest": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"features": {
|
||||||
|
"screenshots": {
|
||||||
|
"quality": 70
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"pdf": {
|
||||||
|
"watermark": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enterprise": {
|
||||||
|
"audit": {
|
||||||
|
"enabled": true,
|
||||||
|
"retention": 365
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"providers": {
|
||||||
|
"storage": {
|
||||||
|
"type": "s3"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"provider": "sendgrid"
|
||||||
|
},
|
||||||
|
"cdn": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"services": {
|
||||||
|
"api": {
|
||||||
|
"cors": {
|
||||||
|
"origin": "https://app.wcag-ada.com"
|
||||||
|
},
|
||||||
|
"rateLimit": {
|
||||||
|
"max": 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"apiUrl": "https://api.wcag-ada.com",
|
||||||
|
"publicUrl": "https://app.wcag-ada.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/wcag-ada/config/config/test.json
Normal file
53
apps/wcag-ada/config/config/test.json
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"environment": "test",
|
||||||
|
|
||||||
|
"log": {
|
||||||
|
"level": "error",
|
||||||
|
"pretty": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"database": {
|
||||||
|
"postgres": {
|
||||||
|
"database": "wcag_ada_test"
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"db": 15
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"scanner": {
|
||||||
|
"concurrency": 1,
|
||||||
|
"timeout": 10000,
|
||||||
|
"headless": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"worker": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"features": {
|
||||||
|
"screenshots": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"providers": {
|
||||||
|
"email": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"services": {
|
||||||
|
"api": {
|
||||||
|
"rateLimit": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/wcag-ada/config/package.json
Normal file
19
apps/wcag-ada/config/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "@wcag-ada/config",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b",
|
||||||
|
"dev": "tsc -w",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@stock-bot/core-config": "workspace:*",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.5",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
apps/wcag-ada/config/src/config-instance.ts
Normal file
67
apps/wcag-ada/config/src/config-instance.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { ConfigManager, createAppConfig } from '@stock-bot/core-config';
|
||||||
|
import { wcagAppConfigSchema, type WcagAppConfig } from './schemas/wcag-app.schema';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
let configInstance: ConfigManager<WcagAppConfig> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the WCAG-ADA configuration
|
||||||
|
* @param serviceName - Optional service name for service-specific overrides
|
||||||
|
*/
|
||||||
|
export function initializeWcagConfig(
|
||||||
|
serviceName?: 'api' | 'dashboard' | 'worker'
|
||||||
|
): WcagAppConfig {
|
||||||
|
if (!configInstance) {
|
||||||
|
configInstance = createAppConfig(wcagAppConfigSchema, {
|
||||||
|
configPath: path.join(__dirname, '../config'),
|
||||||
|
envPrefix: 'WCAG_',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = configInstance.initialize(wcagAppConfigSchema);
|
||||||
|
|
||||||
|
// Apply service-specific overrides if provided
|
||||||
|
if (serviceName && config.services[serviceName]) {
|
||||||
|
const serviceConfig = config.services[serviceName];
|
||||||
|
|
||||||
|
// Override port if specified for the service
|
||||||
|
if (serviceConfig.port && process.env.PORT) {
|
||||||
|
serviceConfig.port = parseInt(process.env.PORT, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current WCAG configuration instance
|
||||||
|
* @throws Error if configuration hasn't been initialized
|
||||||
|
*/
|
||||||
|
export function getWcagConfig(): WcagAppConfig {
|
||||||
|
if (!configInstance) {
|
||||||
|
throw new Error('WCAG configuration not initialized. Call initializeWcagConfig() first.');
|
||||||
|
}
|
||||||
|
return configInstance.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific configuration value by path
|
||||||
|
* @param path - Dot-notation path to the configuration value
|
||||||
|
*/
|
||||||
|
export function getConfigValue<T = unknown>(path: string): T {
|
||||||
|
if (!configInstance) {
|
||||||
|
throw new Error('WCAG configuration not initialized. Call initializeWcagConfig() first.');
|
||||||
|
}
|
||||||
|
return configInstance.getValue<T>(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a configuration path exists
|
||||||
|
* @param path - Dot-notation path to check
|
||||||
|
*/
|
||||||
|
export function hasConfigValue(path: string): boolean {
|
||||||
|
if (!configInstance) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return configInstance.has(path);
|
||||||
|
}
|
||||||
106
apps/wcag-ada/config/src/index.ts
Normal file
106
apps/wcag-ada/config/src/index.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
// Main configuration exports
|
||||||
|
export {
|
||||||
|
initializeWcagConfig,
|
||||||
|
getWcagConfig,
|
||||||
|
getConfigValue,
|
||||||
|
hasConfigValue,
|
||||||
|
} from './config-instance';
|
||||||
|
|
||||||
|
// Schema exports
|
||||||
|
export { wcagAppConfigSchema, type WcagAppConfig } from './schemas/wcag-app.schema';
|
||||||
|
export { scannerConfigSchema, type ScannerConfig } from './schemas/scanner.schema';
|
||||||
|
export { workerConfigSchema, type WorkerConfig } from './schemas/worker.schema';
|
||||||
|
export { featuresConfigSchema, type FeaturesConfig } from './schemas/features.schema';
|
||||||
|
export { providersConfigSchema, type ProvidersConfig } from './schemas/providers.schema';
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
import { getWcagConfig } from './config-instance';
|
||||||
|
import type { ScannerConfig, WorkerConfig, FeaturesConfig, ProvidersConfig } from './schemas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scanner configuration
|
||||||
|
*/
|
||||||
|
export function getScannerConfig(): ScannerConfig {
|
||||||
|
return getWcagConfig().scanner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get worker configuration
|
||||||
|
*/
|
||||||
|
export function getWorkerConfig(): WorkerConfig {
|
||||||
|
return getWcagConfig().worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get features configuration
|
||||||
|
*/
|
||||||
|
export function getFeaturesConfig(): FeaturesConfig {
|
||||||
|
return getWcagConfig().features;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get providers configuration
|
||||||
|
*/
|
||||||
|
export function getProvidersConfig(): ProvidersConfig {
|
||||||
|
return getWcagConfig().providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get service configuration
|
||||||
|
*/
|
||||||
|
export function getServiceConfig(service: 'api' | 'dashboard' | 'worker') {
|
||||||
|
return getWcagConfig().services[service];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a feature is enabled
|
||||||
|
*/
|
||||||
|
export function isFeatureEnabled(feature: string): boolean {
|
||||||
|
const features = getFeaturesConfig();
|
||||||
|
const parts = feature.split('.');
|
||||||
|
|
||||||
|
let current: any = features;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (typeof current !== 'object' || !(part in current)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
current = current[part];
|
||||||
|
}
|
||||||
|
|
||||||
|
return current === true || (typeof current === 'object' && current.enabled === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database configuration
|
||||||
|
*/
|
||||||
|
export function getDatabaseConfig() {
|
||||||
|
return getWcagConfig().database;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Redis configuration for queues
|
||||||
|
*/
|
||||||
|
export function getRedisConfig() {
|
||||||
|
return getWcagConfig().worker.redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage configuration
|
||||||
|
*/
|
||||||
|
export function getStorageConfig() {
|
||||||
|
return getWcagConfig().providers.storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get compliance configuration
|
||||||
|
*/
|
||||||
|
export function getComplianceConfig() {
|
||||||
|
return getWcagConfig().compliance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscription tier configuration
|
||||||
|
*/
|
||||||
|
export function getSubscriptionConfig(tier: 'starter' | 'professional' | 'enterprise') {
|
||||||
|
return getWcagConfig().subscriptions.tiers[tier];
|
||||||
|
}
|
||||||
89
apps/wcag-ada/config/src/schemas/features.schema.ts
Normal file
89
apps/wcag-ada/config/src/schemas/features.schema.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const featuresConfigSchema = z.object({
|
||||||
|
// Scanner features
|
||||||
|
screenshots: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
quality: z.number().min(0).max(100).default(80),
|
||||||
|
fullPage: z.boolean().default(false),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
customRules: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
maxRules: z.number().default(50),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
multiPage: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
maxPages: z.number().default(10),
|
||||||
|
maxDepth: z.number().default(3),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
// API features
|
||||||
|
apiKeys: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
maxPerUser: z.number().default(5),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
webhooks: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
maxPerWebsite: z.number().default(3),
|
||||||
|
retryAttempts: z.number().default(3),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
// Report features
|
||||||
|
reports: z.object({
|
||||||
|
pdf: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
watermark: z.boolean().default(true),
|
||||||
|
}).default({}),
|
||||||
|
excel: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
}).default({}),
|
||||||
|
scheduling: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
// Compliance features
|
||||||
|
compliance: z.object({
|
||||||
|
wcag20: z.boolean().default(true),
|
||||||
|
wcag21: z.boolean().default(true),
|
||||||
|
wcag22: z.boolean().default(true),
|
||||||
|
section508: z.boolean().default(false),
|
||||||
|
ada: z.boolean().default(true),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
// Integration features
|
||||||
|
integrations: z.object({
|
||||||
|
github: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
}).default({}),
|
||||||
|
slack: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
}).default({}),
|
||||||
|
teams: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
}).default({}),
|
||||||
|
jira: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
// Enterprise features
|
||||||
|
enterprise: z.object({
|
||||||
|
sso: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
providers: z.array(z.enum(['saml', 'oauth', 'ldap'])).default([]),
|
||||||
|
}).default({}),
|
||||||
|
whiteLabel: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
}).default({}),
|
||||||
|
audit: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
retention: z.number().default(365), // days
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FeaturesConfig = z.infer<typeof featuresConfigSchema>;
|
||||||
84
apps/wcag-ada/config/src/schemas/providers.schema.ts
Normal file
84
apps/wcag-ada/config/src/schemas/providers.schema.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const providersConfigSchema = z.object({
|
||||||
|
// Storage providers
|
||||||
|
storage: z.object({
|
||||||
|
type: z.enum(['local', 's3', 'gcs', 'azure']).default('local'),
|
||||||
|
local: z.object({
|
||||||
|
basePath: z.string().default('/tmp/wcag-ada'),
|
||||||
|
reports: z.string().default('reports'),
|
||||||
|
screenshots: z.string().default('screenshots'),
|
||||||
|
exports: z.string().default('exports'),
|
||||||
|
}).default({}),
|
||||||
|
s3: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
bucket: z.string().optional(),
|
||||||
|
region: z.string().default('us-east-1'),
|
||||||
|
accessKeyId: z.string().optional(),
|
||||||
|
secretAccessKey: z.string().optional(),
|
||||||
|
endpoint: z.string().optional(),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
// Email providers
|
||||||
|
email: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
provider: z.enum(['smtp', 'sendgrid', 'ses', 'postmark']).default('smtp'),
|
||||||
|
from: z.object({
|
||||||
|
name: z.string().default('WCAG-ADA Compliance'),
|
||||||
|
email: z.string().email().default('noreply@wcag-ada.com'),
|
||||||
|
}).default({}),
|
||||||
|
smtp: z.object({
|
||||||
|
host: z.string().default('localhost'),
|
||||||
|
port: z.number().default(587),
|
||||||
|
secure: z.boolean().default(false),
|
||||||
|
auth: z.object({
|
||||||
|
user: z.string().optional(),
|
||||||
|
pass: z.string().optional(),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
sendgrid: z.object({
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
// Authentication providers
|
||||||
|
auth: z.object({
|
||||||
|
jwt: z.object({
|
||||||
|
secret: z.string().default('change-me-in-production'),
|
||||||
|
expiresIn: z.string().default('7d'),
|
||||||
|
refreshExpiresIn: z.string().default('30d'),
|
||||||
|
}).default({}),
|
||||||
|
oauth: z.object({
|
||||||
|
google: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
clientSecret: z.string().optional(),
|
||||||
|
}).default({}),
|
||||||
|
github: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
clientSecret: z.string().optional(),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
// Analytics providers
|
||||||
|
analytics: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
provider: z.enum(['posthog', 'mixpanel', 'amplitude', 'custom']).default('posthog'),
|
||||||
|
posthog: z.object({
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
host: z.string().default('https://app.posthog.com'),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
// CDN providers
|
||||||
|
cdn: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
provider: z.enum(['cloudflare', 'cloudfront', 'fastly']).default('cloudflare'),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
}).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProvidersConfig = z.infer<typeof providersConfigSchema>;
|
||||||
34
apps/wcag-ada/config/src/schemas/scanner.schema.ts
Normal file
34
apps/wcag-ada/config/src/schemas/scanner.schema.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const scannerConfigSchema = z.object({
|
||||||
|
concurrency: z.number().min(1).max(10).default(2),
|
||||||
|
timeout: z.number().min(30000).default(120000), // 2 minutes default
|
||||||
|
pageLoadTimeout: z.number().min(10000).default(30000),
|
||||||
|
headless: z.boolean().default(true),
|
||||||
|
blockResources: z.boolean().default(true),
|
||||||
|
viewport: z.object({
|
||||||
|
width: z.number().default(1280),
|
||||||
|
height: z.number().default(720),
|
||||||
|
deviceScaleFactor: z.number().default(1),
|
||||||
|
}).default({}),
|
||||||
|
browsers: z.object({
|
||||||
|
chromium: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
args: z.array(z.string()).default(['--no-sandbox', '--disable-setuid-sandbox']),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
axe: z.object({
|
||||||
|
tags: z.array(z.string()).default(['wcag2aa', 'wcag21aa']),
|
||||||
|
resultTypes: z.array(z.enum(['violations', 'passes', 'incomplete', 'inapplicable']))
|
||||||
|
.default(['violations', 'passes', 'incomplete', 'inapplicable']),
|
||||||
|
}).default({}),
|
||||||
|
retries: z.object({
|
||||||
|
maxAttempts: z.number().default(3),
|
||||||
|
backoff: z.object({
|
||||||
|
type: z.enum(['exponential', 'fixed']).default('exponential'),
|
||||||
|
delay: z.number().default(2000),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ScannerConfig = z.infer<typeof scannerConfigSchema>;
|
||||||
111
apps/wcag-ada/config/src/schemas/wcag-app.schema.ts
Normal file
111
apps/wcag-ada/config/src/schemas/wcag-app.schema.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
baseAppConfigSchema,
|
||||||
|
logConfigSchema,
|
||||||
|
databaseConfigSchema,
|
||||||
|
serviceConfigSchema,
|
||||||
|
} from '@stock-bot/core-config';
|
||||||
|
import { scannerConfigSchema } from './scanner.schema';
|
||||||
|
import { workerConfigSchema } from './worker.schema';
|
||||||
|
import { featuresConfigSchema } from './features.schema';
|
||||||
|
import { providersConfigSchema } from './providers.schema';
|
||||||
|
|
||||||
|
// Service-specific configurations
|
||||||
|
const wcagServicesSchema = z.object({
|
||||||
|
api: serviceConfigSchema.extend({
|
||||||
|
port: z.number().default(3001),
|
||||||
|
cors: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
origin: z.string().default('*'),
|
||||||
|
credentials: z.boolean().default(true),
|
||||||
|
}).default({}),
|
||||||
|
rateLimit: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
windowMs: z.number().default(900000), // 15 minutes
|
||||||
|
max: z.number().default(100),
|
||||||
|
keyGenerator: z.enum(['ip', 'userId', 'apiKey']).default('userId'),
|
||||||
|
}).default({}),
|
||||||
|
pagination: z.object({
|
||||||
|
defaultLimit: z.number().default(20),
|
||||||
|
maxLimit: z.number().default(100),
|
||||||
|
}).default({}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
dashboard: serviceConfigSchema.extend({
|
||||||
|
port: z.number().default(3000),
|
||||||
|
apiUrl: z.string().default('http://localhost:3001'),
|
||||||
|
publicUrl: z.string().default('http://localhost:3000'),
|
||||||
|
}),
|
||||||
|
|
||||||
|
worker: serviceConfigSchema.extend({
|
||||||
|
port: z.number().default(3002), // For health checks
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main WCAG application configuration schema
|
||||||
|
export const wcagAppConfigSchema = baseAppConfigSchema.extend({
|
||||||
|
appName: z.literal('wcag-ada').default('wcag-ada'),
|
||||||
|
|
||||||
|
// Core configurations
|
||||||
|
log: logConfigSchema,
|
||||||
|
database: databaseConfigSchema,
|
||||||
|
|
||||||
|
// WCAG-specific configurations
|
||||||
|
scanner: scannerConfigSchema,
|
||||||
|
worker: workerConfigSchema,
|
||||||
|
features: featuresConfigSchema,
|
||||||
|
providers: providersConfigSchema,
|
||||||
|
|
||||||
|
// Service configurations
|
||||||
|
services: wcagServicesSchema.default({}),
|
||||||
|
|
||||||
|
// Business logic configurations
|
||||||
|
compliance: z.object({
|
||||||
|
defaultLevel: z.object({
|
||||||
|
standard: z.enum(['WCAG20', 'WCAG21', 'WCAG22']).default('WCAG21'),
|
||||||
|
level: z.enum(['A', 'AA', 'AAA']).default('AA'),
|
||||||
|
}).default({}),
|
||||||
|
passingScore: z.object({
|
||||||
|
A: z.number().min(0).max(100).default(95),
|
||||||
|
AA: z.number().min(0).max(100).default(98),
|
||||||
|
AAA: z.number().min(0).max(100).default(100),
|
||||||
|
}).default({}),
|
||||||
|
criticalCriteria: z.array(z.string()).default([
|
||||||
|
'1.1.1', // Non-text Content
|
||||||
|
'1.3.1', // Info and Relationships
|
||||||
|
'1.4.3', // Contrast (Minimum)
|
||||||
|
'2.1.1', // Keyboard
|
||||||
|
'2.1.2', // No Keyboard Trap
|
||||||
|
'2.4.1', // Bypass Blocks
|
||||||
|
'2.4.2', // Page Titled
|
||||||
|
'4.1.2', // Name, Role, Value
|
||||||
|
]),
|
||||||
|
}).default({}),
|
||||||
|
|
||||||
|
// Subscription/pricing tiers
|
||||||
|
subscriptions: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
tiers: z.object({
|
||||||
|
starter: z.object({
|
||||||
|
websites: z.number().default(5),
|
||||||
|
scansPerMonth: z.number().default(500),
|
||||||
|
users: z.number().default(1),
|
||||||
|
price: z.number().default(49),
|
||||||
|
}).default({}),
|
||||||
|
professional: z.object({
|
||||||
|
websites: z.number().default(25),
|
||||||
|
scansPerMonth: z.number().default(5000),
|
||||||
|
users: z.number().default(5),
|
||||||
|
price: z.number().default(149),
|
||||||
|
}).default({}),
|
||||||
|
enterprise: z.object({
|
||||||
|
websites: z.number().default(-1), // Unlimited
|
||||||
|
scansPerMonth: z.number().default(-1),
|
||||||
|
users: z.number().default(-1),
|
||||||
|
price: z.number().default(499),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WcagAppConfig = z.infer<typeof wcagAppConfigSchema>;
|
||||||
49
apps/wcag-ada/config/src/schemas/worker.schema.ts
Normal file
49
apps/wcag-ada/config/src/schemas/worker.schema.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const workerConfigSchema = z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
concurrency: z.number().min(1).max(10).default(2),
|
||||||
|
queueName: z.string().default('accessibility-scans'),
|
||||||
|
redis: z.object({
|
||||||
|
host: z.string().default('localhost'),
|
||||||
|
port: z.number().default(6379),
|
||||||
|
password: z.string().optional(),
|
||||||
|
db: z.number().default(2), // Different DB for WCAG
|
||||||
|
maxRetriesPerRequest: z.number().nullable().default(null),
|
||||||
|
}).default({}),
|
||||||
|
jobs: z.object({
|
||||||
|
scan: z.object({
|
||||||
|
priority: z.number().default(0),
|
||||||
|
attempts: z.number().default(3),
|
||||||
|
backoff: z.object({
|
||||||
|
type: z.enum(['exponential', 'fixed']).default('exponential'),
|
||||||
|
delay: z.number().default(2000),
|
||||||
|
}).default({}),
|
||||||
|
timeout: z.number().default(300000), // 5 minutes
|
||||||
|
removeOnComplete: z.object({
|
||||||
|
age: z.number().default(86400), // 24 hours
|
||||||
|
count: z.number().default(100),
|
||||||
|
}).default({}),
|
||||||
|
removeOnFail: z.object({
|
||||||
|
age: z.number().default(604800), // 7 days
|
||||||
|
count: z.number().default(500),
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
report: z.object({
|
||||||
|
priority: z.number().default(1),
|
||||||
|
attempts: z.number().default(2),
|
||||||
|
backoff: z.object({
|
||||||
|
type: z.enum(['exponential', 'fixed']).default('exponential'),
|
||||||
|
delay: z.number().default(5000),
|
||||||
|
}).default({}),
|
||||||
|
timeout: z.number().default(600000), // 10 minutes
|
||||||
|
}).default({}),
|
||||||
|
}).default({}),
|
||||||
|
scheduler: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
interval: z.number().default(60000), // Check every minute
|
||||||
|
timezone: z.string().default('UTC'),
|
||||||
|
}).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WorkerConfig = z.infer<typeof workerConfigSchema>;
|
||||||
13
apps/wcag-ada/config/tsconfig.json
Normal file
13
apps/wcag-ada/config/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../../../libs/core/config" }
|
||||||
|
]
|
||||||
|
}
|
||||||
57
apps/wcag-ada/dashboard/Dockerfile
Normal file
57
apps/wcag-ada/dashboard/Dockerfile
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Build stage
|
||||||
|
FROM oven/bun:1-alpine as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy workspace files
|
||||||
|
COPY package.json bun.lockb ./
|
||||||
|
COPY apps/wcag-ada/dashboard/package.json ./apps/wcag-ada/dashboard/
|
||||||
|
COPY apps/wcag-ada/shared/package.json ./apps/wcag-ada/shared/
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY apps/wcag-ada/dashboard ./apps/wcag-ada/dashboard
|
||||||
|
COPY apps/wcag-ada/shared ./apps/wcag-ada/shared
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
WORKDIR /app/apps/wcag-ada/dashboard
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Production stage with nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY apps/wcag-ada/dashboard/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/dashboard/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
RUN chown -R nodejs:nodejs /usr/share/nginx/html && \
|
||||||
|
chown -R nodejs:nodejs /var/cache/nginx && \
|
||||||
|
chown -R nodejs:nodejs /var/log/nginx && \
|
||||||
|
chown -R nodejs:nodejs /etc/nginx/conf.d && \
|
||||||
|
touch /var/run/nginx.pid && \
|
||||||
|
chown -R nodejs:nodejs /var/run/nginx.pid
|
||||||
|
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/ || exit 1
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
13
apps/wcag-ada/dashboard/index.html
Normal file
13
apps/wcag-ada/dashboard/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>WCAG-ADA Compliance Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
79
apps/wcag-ada/dashboard/nginx.conf
Normal file
79
apps/wcag-ada/dashboard/nginx.conf
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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/xml+rss application/rss+xml application/atom+xml image/svg+xml;
|
||||||
|
|
||||||
|
# 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 "no-referrer-when-downgrade" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'" always;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serve index.html for all routes (React Router)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
add_header Expires "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy (if needed in production)
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://wcag-ada-api:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
apps/wcag-ada/dashboard/package.json
Normal file
61
apps/wcag-ada/dashboard/package.json
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"name": "@wcag-ada/dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint src"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.1",
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"zustand": "^4.4.7",
|
||||||
|
"@wcag-ada/shared": "workspace:*",
|
||||||
|
"@wcag-ada/config": "workspace:*",
|
||||||
|
"recharts": "^2.10.3",
|
||||||
|
"date-fns": "^3.0.6",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"lucide-react": "^0.303.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"react-hook-form": "^7.48.2",
|
||||||
|
"@hookform/resolvers": "^3.3.4",
|
||||||
|
"zod": "^3.22.4",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"tailwind-merge": "^2.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.47",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.18.1",
|
||||||
|
"@typescript-eslint/parser": "^6.18.1",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/wcag-ada/dashboard/postcss.config.js
Normal file
6
apps/wcag-ada/dashboard/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
5
apps/wcag-ada/dashboard/public/icon.svg
Normal file
5
apps/wcag-ada/dashboard/public/icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#3B82F6"/>
|
||||||
|
<path d="M16 6C10.4772 6 6 10.4772 6 16C6 21.5228 10.4772 26 16 26C21.5228 26 26 21.5228 26 16C26 10.4772 21.5228 6 16 6ZM16 8C20.4183 8 24 11.5817 24 16C24 20.4183 20.4183 24 16 24C11.5817 24 8 20.4183 8 16C8 11.5817 11.5817 8 16 8Z" fill="white"/>
|
||||||
|
<path d="M16 10C12.6863 10 10 12.6863 10 16C10 19.3137 12.6863 22 16 22C19.3137 22 22 19.3137 22 16C22 12.6863 19.3137 10 16 10ZM13 15H15V17H13V15ZM17 15H19V17H17V15Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 594 B |
70
apps/wcag-ada/dashboard/src/App.tsx
Normal file
70
apps/wcag-ada/dashboard/src/App.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { MainLayout } from '@/components/layout/main-layout';
|
||||||
|
import { AuthLayout } from '@/components/layout/auth-layout';
|
||||||
|
import { ProtectedRoute } from '@/components/layout/protected-route';
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
import { LoginPage } from '@/pages/auth/login';
|
||||||
|
import { RegisterPage } from '@/pages/auth/register';
|
||||||
|
import { DashboardPage } from '@/pages/dashboard';
|
||||||
|
import { WebsitesPage } from '@/pages/websites';
|
||||||
|
import { WebsiteDetailPage } from '@/pages/websites/[id]';
|
||||||
|
import { ScansPage } from '@/pages/scans';
|
||||||
|
import { ScanDetailPage } from '@/pages/scans/[id]';
|
||||||
|
import { ReportsPage } from '@/pages/reports';
|
||||||
|
import { SettingsPage } from '@/pages/settings';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { setAuth, setLoading, token } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initAuth = async () => {
|
||||||
|
if (!token) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await apiClient.getMe();
|
||||||
|
setAuth(user, token);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check failed:', error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initAuth();
|
||||||
|
}, [token, setAuth, setLoading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Auth routes */}
|
||||||
|
<Route element={<AuthLayout />}>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route element={<MainLayout />}>
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="/websites" element={<WebsitesPage />} />
|
||||||
|
<Route path="/websites/:id" element={<WebsiteDetailPage />} />
|
||||||
|
<Route path="/scans" element={<ScansPage />} />
|
||||||
|
<Route path="/scans/:id" element={<ScanDetailPage />} />
|
||||||
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* 404 */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
|
||||||
|
export function AuthLayout() {
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
WCAG-ADA Compliance
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
||||||
|
Automated accessibility testing and compliance monitoring
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
apps/wcag-ada/dashboard/src/components/layout/main-layout.tsx
Normal file
189
apps/wcag-ada/dashboard/src/components/layout/main-layout.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
Globe,
|
||||||
|
Scan,
|
||||||
|
FileText,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
Shield,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
||||||
|
{ name: 'Websites', href: '/websites', icon: Globe },
|
||||||
|
{ name: 'Scans', href: '/scans', icon: Scan },
|
||||||
|
{ name: 'Reports', href: '/reports', icon: FileText },
|
||||||
|
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MainLayout() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* Mobile sidebar */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 lg:hidden',
|
||||||
|
sidebarOpen ? 'block' : 'hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-900/80"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white dark:bg-gray-800">
|
||||||
|
<div className="flex h-16 items-center justify-between px-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-8 w-8 text-primary" />
|
||||||
|
<span className="text-xl font-semibold">WCAG-ADA</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = location.pathname.startsWith(item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
)}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="border-t p-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{user?.name?.[0] || user?.email[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{user?.name || user?.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{user?.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop sidebar */}
|
||||||
|
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
||||||
|
<div className="flex flex-1 flex-col bg-white dark:bg-gray-800 border-r">
|
||||||
|
<div className="flex h-16 items-center px-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-8 w-8 text-primary" />
|
||||||
|
<span className="text-xl font-semibold">WCAG-ADA</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = location.pathname.startsWith(item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="border-t p-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{user?.name?.[0] || user?.email[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{user?.name || user?.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{user?.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="lg:pl-64">
|
||||||
|
<div className="sticky top-0 z-40 flex h-16 items-center gap-4 border-b bg-white dark:bg-gray-800 px-4 sm:px-6 lg:px-8">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
{navigation.find((n) => location.pathname.startsWith(n.href))?.name || 'WCAG-ADA Compliance'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<main className="py-6">
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export function ProtectedRoute() {
|
||||||
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
55
apps/wcag-ada/dashboard/src/components/ui/button.tsx
Normal file
55
apps/wcag-ada/dashboard/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
78
apps/wcag-ada/dashboard/src/components/ui/card.tsx
Normal file
78
apps/wcag-ada/dashboard/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl font-semibold leading-none tracking-tight',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
167
apps/wcag-ada/dashboard/src/lib/api-client.ts
Normal file
167
apps/wcag-ada/dashboard/src/lib/api-client.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor to add auth token
|
||||||
|
this.client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = useAuthStore.getState().token;
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor to handle errors
|
||||||
|
this.client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Token expired or invalid
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth endpoints
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
const response = await this.client.post('/auth/login', { email, password });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(data: { email: string; password: string; name?: string; company?: string }) {
|
||||||
|
const response = await this.client.post('/auth/register', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMe() {
|
||||||
|
const response = await this.client.get('/auth/me');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshApiKey() {
|
||||||
|
const response = await this.client.post('/auth/refresh-api-key');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Website endpoints
|
||||||
|
async getWebsites(params?: { page?: number; limit?: number; search?: string }) {
|
||||||
|
const response = await this.client.get('/websites', { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWebsite(id: string) {
|
||||||
|
const response = await this.client.get(`/websites/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWebsite(data: any) {
|
||||||
|
const response = await this.client.post('/websites', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWebsite(id: string, data: any) {
|
||||||
|
const response = await this.client.patch(`/websites/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWebsite(id: string) {
|
||||||
|
const response = await this.client.delete(`/websites/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWebsiteScans(id: string, params?: { page?: number; limit?: number }) {
|
||||||
|
const response = await this.client.get(`/websites/${id}/scans`, { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan endpoints
|
||||||
|
async createScan(data: { websiteId: string; url?: string; options?: any }) {
|
||||||
|
const response = await this.client.post('/scans', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScan(id: string) {
|
||||||
|
const response = await this.client.get(`/scans/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScanResult(id: string) {
|
||||||
|
const response = await this.client.get(`/scans/${id}/result`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScanViolations(id: string, params?: { impact?: string; tag?: string }) {
|
||||||
|
const response = await this.client.get(`/scans/${id}/violations`, { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelScan(id: string) {
|
||||||
|
const response = await this.client.delete(`/scans/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScans(params?: { page?: number; limit?: number; status?: string; websiteId?: string }) {
|
||||||
|
const response = await this.client.get('/scans', { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report endpoints
|
||||||
|
async generateReport(data: {
|
||||||
|
websiteId: string;
|
||||||
|
type: 'COMPLIANCE' | 'EXECUTIVE' | 'TECHNICAL' | 'TREND';
|
||||||
|
format: 'PDF' | 'HTML' | 'JSON' | 'CSV';
|
||||||
|
period: { start: string; end: string };
|
||||||
|
}) {
|
||||||
|
const response = await this.client.post('/reports/generate', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReports(params?: { page?: number; limit?: number; websiteId?: string; type?: string }) {
|
||||||
|
const response = await this.client.get('/reports', { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReport(id: string) {
|
||||||
|
const response = await this.client.get(`/reports/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadReport(id: string) {
|
||||||
|
const response = await this.client.get(`/reports/${id}/download`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getComplianceTrends(websiteId: string, days: number = 30) {
|
||||||
|
const response = await this.client.get(`/reports/trends/${websiteId}`, { params: { days } });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health endpoints
|
||||||
|
async getHealth() {
|
||||||
|
const response = await this.client.get('/health');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats() {
|
||||||
|
const response = await this.client.get('/health/stats');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new ApiClient();
|
||||||
6
apps/wcag-ada/dashboard/src/lib/utils.ts
Normal file
6
apps/wcag-ada/dashboard/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
27
apps/wcag-ada/dashboard/src/main.tsx
Normal file
27
apps/wcag-ada/dashboard/src/main.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './styles/globals.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
112
apps/wcag-ada/dashboard/src/pages/auth/login.tsx
Normal file
112
apps/wcag-ada/dashboard/src/pages/auth/login.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginForm = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setAuth } = useAuthStore();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginForm>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.login(data.email, data.password);
|
||||||
|
setAuth(response.user, response.token);
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Login failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="sr-only">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('email')}
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
placeholder="Email address"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="sr-only">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('password')}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="font-medium text-primary hover:text-primary/80"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
apps/wcag-ada/dashboard/src/pages/auth/register.tsx
Normal file
141
apps/wcag-ada/dashboard/src/pages/auth/register.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
name: z.string().optional(),
|
||||||
|
company: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RegisterForm = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setAuth } = useAuthStore();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<RegisterForm>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.register(data);
|
||||||
|
setAuth(response.user, response.token);
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Registration failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('email')}
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
placeholder="Email address"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('password')}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Name (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('name')}
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="company" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Company (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('company')}
|
||||||
|
type="text"
|
||||||
|
autoComplete="organization"
|
||||||
|
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
placeholder="Your company"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="font-medium text-primary hover:text-primary/80"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
apps/wcag-ada/dashboard/src/pages/dashboard/index.tsx
Normal file
243
apps/wcag-ada/dashboard/src/pages/dashboard/index.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Loader2, Globe, Scan, AlertCircle, CheckCircle2, TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { LineChart, Line, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||||
|
queryKey: ['stats'],
|
||||||
|
queryFn: () => apiClient.getStats(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: recentScans } = useQuery({
|
||||||
|
queryKey: ['recent-scans'],
|
||||||
|
queryFn: () => apiClient.getScans({ limit: 5 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: websites } = useQuery({
|
||||||
|
queryKey: ['websites-summary'],
|
||||||
|
queryFn: () => apiClient.getWebsites({ limit: 5 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statsLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Websites</CardTitle>
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats?.database?.websites || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Active websites being monitored
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Scans</CardTitle>
|
||||||
|
<Scan className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats?.database?.scanResults || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Accessibility scans performed
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Queue Status</CardTitle>
|
||||||
|
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats?.queue?.active || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Active scans in progress
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{stats?.queue?.completed && stats?.queue?.failed
|
||||||
|
? Math.round((stats.queue.completed / (stats.queue.completed + stats.queue.failed)) * 100)
|
||||||
|
: 100}%
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Scan completion rate
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Row */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Compliance Trends</CardTitle>
|
||||||
|
<CardDescription>Average compliance scores over time</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart
|
||||||
|
data={[
|
||||||
|
{ date: 'Mon', score: 85 },
|
||||||
|
{ date: 'Tue', score: 87 },
|
||||||
|
{ date: 'Wed', score: 86 },
|
||||||
|
{ date: 'Thu', score: 89 },
|
||||||
|
{ date: 'Fri', score: 91 },
|
||||||
|
{ date: 'Sat', score: 90 },
|
||||||
|
{ date: 'Sun', score: 92 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis domain={[0, 100]} />
|
||||||
|
<Tooltip />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="score"
|
||||||
|
stroke="hsl(var(--primary))"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Issue Distribution</CardTitle>
|
||||||
|
<CardDescription>Violations by severity level</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={[
|
||||||
|
{ severity: 'Critical', count: 5, fill: 'hsl(var(--destructive))' },
|
||||||
|
{ severity: 'Serious', count: 12, fill: 'hsl(var(--warning))' },
|
||||||
|
{ severity: 'Moderate', count: 25, fill: 'hsl(var(--primary))' },
|
||||||
|
{ severity: 'Minor', count: 45, fill: 'hsl(var(--muted))' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="severity" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="count" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Recent Scans</CardTitle>
|
||||||
|
<CardDescription>Latest accessibility scan results</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to="/scans">View all</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentScans?.scans?.map((scan: any) => (
|
||||||
|
<div key={scan.id} className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{scan.website?.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{format(new Date(scan.createdAt), 'MMM d, h:mm a')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{scan.status === 'COMPLETED' ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{scan.result?.summary?.score || 0}%
|
||||||
|
</span>
|
||||||
|
{scan.result?.summary?.score > 90 ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-success" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-4 w-4 text-destructive" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{scan.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Websites</CardTitle>
|
||||||
|
<CardDescription>Your monitored websites</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to="/websites">Manage</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{websites?.websites?.map((website: any) => (
|
||||||
|
<div key={website.id} className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{website.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{website.url}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{website.complianceScore !== null ? (
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{Math.round(website.complianceScore)}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
No scans yet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/wcag-ada/dashboard/src/pages/reports/index.tsx
Normal file
8
apps/wcag-ada/dashboard/src/pages/reports/index.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export function ReportsPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Reports</h1>
|
||||||
|
<p className="text-muted-foreground">Compliance reports and analytics coming soon...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/wcag-ada/dashboard/src/pages/scans/[id].tsx
Normal file
8
apps/wcag-ada/dashboard/src/pages/scans/[id].tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export function ScanDetailPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Scan Results</h1>
|
||||||
|
<p className="text-muted-foreground">Scan detail page coming soon...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/wcag-ada/dashboard/src/pages/scans/index.tsx
Normal file
8
apps/wcag-ada/dashboard/src/pages/scans/index.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export function ScansPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Scans</h1>
|
||||||
|
<p className="text-muted-foreground">Scan history and management coming soon...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/wcag-ada/dashboard/src/pages/settings/index.tsx
Normal file
8
apps/wcag-ada/dashboard/src/pages/settings/index.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
|
<p className="text-muted-foreground">Account settings and preferences coming soon...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/wcag-ada/dashboard/src/pages/websites/[id].tsx
Normal file
8
apps/wcag-ada/dashboard/src/pages/websites/[id].tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export function WebsiteDetailPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Website Details</h1>
|
||||||
|
<p className="text-muted-foreground">Website detail page coming soon...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
apps/wcag-ada/dashboard/src/pages/websites/index.tsx
Normal file
88
apps/wcag-ada/dashboard/src/pages/websites/index.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Plus, Globe, ExternalLink, MoreVertical } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export function WebsitesPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['websites', page],
|
||||||
|
queryFn: () => apiClient.getWebsites({ page, limit: 12 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Websites</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage your monitored websites and their scan schedules
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Website
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<Card key={i} className="h-48 animate-pulse bg-muted" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{data?.websites?.map((website: any) => (
|
||||||
|
<Card key={website.id} className="p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<Globe className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="font-semibold">{website.name}</h3>
|
||||||
|
<a
|
||||||
|
href={website.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1 mt-1"
|
||||||
|
>
|
||||||
|
{website.url}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
{website.complianceScore !== null ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-2xl font-bold">
|
||||||
|
{Math.round(website.complianceScore)}%
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-muted-foreground">Compliance</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No scans yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to={`/websites/${website.id}`}>View Details</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{website.lastScanAt && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Last scan: {format(new Date(website.lastScanAt), 'MMM d, h:mm a')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/wcag-ada/dashboard/src/store/auth-store.ts
Normal file
55
apps/wcag-ada/dashboard/src/store/auth-store.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
company?: string;
|
||||||
|
role: string;
|
||||||
|
apiKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
setAuth: (user: User, token: string) => void;
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: true,
|
||||||
|
setAuth: (user, token) =>
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
logout: () =>
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'wcag-auth',
|
||||||
|
partialize: (state) => ({
|
||||||
|
token: state.token,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
67
apps/wcag-ada/dashboard/src/styles/globals.css
Normal file
67
apps/wcag-ada/dashboard/src/styles/globals.css
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--success: 142.1 76.2% 36.3%;
|
||||||
|
--success-foreground: 355.7 100% 97.3%;
|
||||||
|
--warning: 47.9 95.8% 53.1%;
|
||||||
|
--warning-foreground: 26 83.3% 14.1%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--success: 142.1 70.6% 45.3%;
|
||||||
|
--success-foreground: 144.9 80.4% 10%;
|
||||||
|
--warning: 47.9 80% 60%;
|
||||||
|
--warning-foreground: 47.5 90% 10%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 224.3 76.3% 48%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
apps/wcag-ada/dashboard/tailwind.config.js
Normal file
85
apps/wcag-ada/dashboard/tailwind.config.js
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
'./pages/**/*.{ts,tsx}',
|
||||||
|
'./components/**/*.{ts,tsx}',
|
||||||
|
'./app/**/*.{ts,tsx}',
|
||||||
|
'./src/**/*.{ts,tsx}',
|
||||||
|
],
|
||||||
|
prefix: "",
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
DEFAULT: "hsl(var(--success))",
|
||||||
|
foreground: "hsl(var(--success-foreground))",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: "hsl(var(--warning))",
|
||||||
|
foreground: "hsl(var(--warning-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
}
|
||||||
34
apps/wcag-ada/dashboard/tsconfig.json
Normal file
34
apps/wcag-ada/dashboard/tsconfig.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"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 aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../shared" },
|
||||||
|
{ "path": "../config" }
|
||||||
|
]
|
||||||
|
}
|
||||||
22
apps/wcag-ada/dashboard/vite.config.ts
Normal file
22
apps/wcag-ada/dashboard/vite.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
47
apps/wcag-ada/docker-compose.dev.yml
Normal file
47
apps/wcag-ada/docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database (for local development)
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: wcag-ada-postgres-dev
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: wcag_ada_dev
|
||||||
|
volumes:
|
||||||
|
- postgres_dev_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d wcag_ada_dev"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- wcag-ada-dev-network
|
||||||
|
|
||||||
|
# Redis (for local development)
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: wcag-ada-redis-dev
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
volumes:
|
||||||
|
- redis_dev_data:/data
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- wcag-ada-dev-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
wcag-ada-dev-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_dev_data:
|
||||||
|
redis_dev_data:
|
||||||
28
apps/wcag-ada/docker-compose.test.yml
Normal file
28
apps/wcag-ada/docker-compose.test.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Test databases for CI/CD
|
||||||
|
postgres-test:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: test_user
|
||||||
|
POSTGRES_PASSWORD: test_password
|
||||||
|
POSTGRES_DB: wcag_ada_test
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U test_user -d wcag_ada_test"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis-test:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
ports:
|
||||||
|
- "6380:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
153
apps/wcag-ada/docker-compose.yml
Normal file
153
apps/wcag-ada/docker-compose.yml
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: wcag-ada-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: wcag_user
|
||||||
|
POSTGRES_PASSWORD: wcag_password
|
||||||
|
POSTGRES_DB: wcag_ada
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U wcag_user -d wcag_ada"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- wcag-ada-network
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: wcag-ada-redis
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- wcag-ada-network
|
||||||
|
|
||||||
|
# API Service
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: apps/wcag-ada/api/Dockerfile
|
||||||
|
container_name: wcag-ada-api
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: postgresql://wcag_user:wcag_password@postgres:5432/wcag_ada
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
API_PORT: 3001
|
||||||
|
API_JWT_SECRET: ${API_JWT_SECRET:-change-this-in-production}
|
||||||
|
API_CORS_ORIGIN: ${API_CORS_ORIGIN:-http://localhost:8080}
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- wcag-ada-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Worker Service
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: apps/wcag-ada/worker/Dockerfile
|
||||||
|
container_name: wcag-ada-worker
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: postgresql://wcag_user:wcag_password@postgres:5432/wcag_ada
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
WORKER_PORT: 3002
|
||||||
|
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5}
|
||||||
|
SCANNER_HEADLESS: "true"
|
||||||
|
ports:
|
||||||
|
- "3002:3002"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- wcag-ada-network
|
||||||
|
restart: unless-stopped
|
||||||
|
# Additional security options for Chromium
|
||||||
|
security_opt:
|
||||||
|
- seccomp:unconfined
|
||||||
|
cap_add:
|
||||||
|
- SYS_ADMIN
|
||||||
|
|
||||||
|
# Dashboard
|
||||||
|
dashboard:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: apps/wcag-ada/dashboard/Dockerfile
|
||||||
|
container_name: wcag-ada-dashboard
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- wcag-ada-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Database migrations (one-time job)
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: apps/wcag-ada/api/Dockerfile
|
||||||
|
container_name: wcag-ada-migrate
|
||||||
|
command: ["bunx", "prisma", "migrate", "deploy"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://wcag_user:wcag_password@postgres:5432/wcag_ada
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- wcag-ada-network
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
wcag-ada-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
69
apps/wcag-ada/k8s/api.yaml
Normal file
69
apps/wcag-ada/k8s/api.yaml
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: wcag-ada-api
|
||||||
|
namespace: wcag-ada
|
||||||
|
spec:
|
||||||
|
replicas: 3
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: wcag-ada-api
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: wcag-ada-api
|
||||||
|
spec:
|
||||||
|
initContainers:
|
||||||
|
- name: wait-for-postgres
|
||||||
|
image: busybox:1.36
|
||||||
|
command: ['sh', '-c', 'until nc -z postgres-service 5432; do echo waiting for postgres; sleep 2; done']
|
||||||
|
- name: run-migrations
|
||||||
|
image: wcag-ada/api:latest
|
||||||
|
command: ["bunx", "prisma", "migrate", "deploy"]
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: wcag-ada-config
|
||||||
|
- secretRef:
|
||||||
|
name: wcag-ada-secrets
|
||||||
|
containers:
|
||||||
|
- name: api
|
||||||
|
image: wcag-ada/api:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 3001
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: wcag-ada-config
|
||||||
|
- secretRef:
|
||||||
|
name: wcag-ada-secrets
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3001
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3001
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: wcag-ada-api-service
|
||||||
|
namespace: wcag-ada
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: wcag-ada-api
|
||||||
|
ports:
|
||||||
|
- port: 3001
|
||||||
|
targetPort: 3001
|
||||||
|
type: ClusterIP
|
||||||
47
apps/wcag-ada/k8s/configmap.yaml
Normal file
47
apps/wcag-ada/k8s/configmap.yaml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: wcag-ada-config
|
||||||
|
namespace: wcag-ada
|
||||||
|
data:
|
||||||
|
NODE_ENV: "production"
|
||||||
|
APP_NAME: "wcag-ada"
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
API_PORT: "3001"
|
||||||
|
API_CORS_ORIGIN: "https://wcag-ada.example.com"
|
||||||
|
API_JWT_EXPIRES_IN: "7d"
|
||||||
|
API_RATE_LIMIT: "100"
|
||||||
|
API_RATE_WINDOW: "60000"
|
||||||
|
|
||||||
|
# Worker Configuration
|
||||||
|
WORKER_PORT: "3002"
|
||||||
|
WORKER_CONCURRENCY: "5"
|
||||||
|
WORKER_QUEUE_NAME: "accessibility-scans"
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_HOST: "redis-service"
|
||||||
|
REDIS_PORT: "6379"
|
||||||
|
REDIS_DB: "0"
|
||||||
|
|
||||||
|
# Scanner Configuration
|
||||||
|
SCANNER_HEADLESS: "true"
|
||||||
|
SCANNER_TIMEOUT: "30000"
|
||||||
|
SCANNER_VIEWPORT_WIDTH: "1920"
|
||||||
|
SCANNER_VIEWPORT_HEIGHT: "1080"
|
||||||
|
SCANNER_BLOCK_RESOURCES: "true"
|
||||||
|
SCANNER_BLOCK_PATTERNS: "font,image"
|
||||||
|
|
||||||
|
# Features
|
||||||
|
FEATURES_SCREENSHOTS: "true"
|
||||||
|
FEATURES_FIX_SUGGESTIONS: "true"
|
||||||
|
FEATURES_CUSTOM_RULES: "true"
|
||||||
|
FEATURES_SCHEDULED_SCANS: "true"
|
||||||
|
FEATURES_BULK_SCANNING: "false"
|
||||||
|
FEATURES_API_ACCESS: "true"
|
||||||
|
|
||||||
|
# Scheduler
|
||||||
|
SCHEDULER_TIMEZONE: "America/New_York"
|
||||||
|
SCHEDULER_MAX_CONCURRENT_SCANS: "3"
|
||||||
|
SCHEDULER_RETRY_ATTEMPTS: "3"
|
||||||
|
SCHEDULER_RETRY_DELAY: "5000"
|
||||||
52
apps/wcag-ada/k8s/dashboard.yaml
Normal file
52
apps/wcag-ada/k8s/dashboard.yaml
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: wcag-ada-dashboard
|
||||||
|
namespace: wcag-ada
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: wcag-ada-dashboard
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: wcag-ada-dashboard
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: dashboard
|
||||||
|
image: wcag-ada/dashboard:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: "50m"
|
||||||
|
limits:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: wcag-ada-dashboard-service
|
||||||
|
namespace: wcag-ada
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: wcag-ada-dashboard
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8080
|
||||||
|
type: ClusterIP
|
||||||
38
apps/wcag-ada/k8s/ingress.yaml
Normal file
38
apps/wcag-ada/k8s/ingress.yaml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: wcag-ada-ingress
|
||||||
|
namespace: wcag-ada
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- wcag-ada.example.com
|
||||||
|
- api.wcag-ada.example.com
|
||||||
|
secretName: wcag-ada-tls
|
||||||
|
rules:
|
||||||
|
- host: wcag-ada.example.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: wcag-ada-dashboard-service
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
- host: api.wcag-ada.example.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: wcag-ada-api-service
|
||||||
|
port:
|
||||||
|
number: 3001
|
||||||
6
apps/wcag-ada/k8s/namespace.yaml
Normal file
6
apps/wcag-ada/k8s/namespace.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: wcag-ada
|
||||||
|
labels:
|
||||||
|
name: wcag-ada
|
||||||
84
apps/wcag-ada/k8s/postgres.yaml
Normal file
84
apps/wcag-ada/k8s/postgres.yaml
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: postgres-pvc
|
||||||
|
namespace: wcag-ada
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: wcag-ada
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
value: wcag_user
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: wcag-ada-secrets
|
||||||
|
key: DATABASE_URL
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: wcag_ada
|
||||||
|
- name: PGDATA
|
||||||
|
value: /var/lib/postgresql/data/pgdata
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
volumeMounts:
|
||||||
|
- name: postgres-storage
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- pg_isready
|
||||||
|
- -U
|
||||||
|
- wcag_user
|
||||||
|
- -d
|
||||||
|
- wcag_ada
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- pg_isready
|
||||||
|
- -U
|
||||||
|
- wcag_user
|
||||||
|
- -d
|
||||||
|
- wcag_ada
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
volumes:
|
||||||
|
- name: postgres-storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: postgres-pvc
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres-service
|
||||||
|
namespace: wcag-ada
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
|
type: ClusterIP
|
||||||
77
apps/wcag-ada/k8s/redis.yaml
Normal file
77
apps/wcag-ada/k8s/redis.yaml
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: redis-pvc
|
||||||
|
namespace: wcag-ada
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: redis
|
||||||
|
namespace: wcag-ada
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: redis
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: redis
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: redis
|
||||||
|
image: redis:7-alpine
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
- --appendonly
|
||||||
|
- "yes"
|
||||||
|
ports:
|
||||||
|
- containerPort: 6379
|
||||||
|
volumeMounts:
|
||||||
|
- name: redis-storage
|
||||||
|
mountPath: /data
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- redis-cli
|
||||||
|
- ping
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- redis-cli
|
||||||
|
- ping
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "200m"
|
||||||
|
volumes:
|
||||||
|
- name: redis-storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: redis-pvc
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: redis-service
|
||||||
|
namespace: wcag-ada
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: redis
|
||||||
|
ports:
|
||||||
|
- port: 6379
|
||||||
|
targetPort: 6379
|
||||||
|
type: ClusterIP
|
||||||
26
apps/wcag-ada/k8s/secrets.yaml
Normal file
26
apps/wcag-ada/k8s/secrets.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: wcag-ada-secrets
|
||||||
|
namespace: wcag-ada
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: "postgresql://wcag_user:wcag_password@postgres-service:5432/wcag_ada"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_PASSWORD: ""
|
||||||
|
|
||||||
|
# API
|
||||||
|
API_JWT_SECRET: "your-super-secret-jwt-key-change-in-production"
|
||||||
|
|
||||||
|
# Email (optional)
|
||||||
|
SMTP_HOST: ""
|
||||||
|
SMTP_PORT: "587"
|
||||||
|
SMTP_USER: ""
|
||||||
|
SMTP_PASS: ""
|
||||||
|
SMTP_FROM: "noreply@wcag-ada.com"
|
||||||
|
|
||||||
|
# Slack (optional)
|
||||||
|
SLACK_WEBHOOK_URL: ""
|
||||||
|
SLACK_CHANNEL: "#wcag-alerts"
|
||||||
67
apps/wcag-ada/k8s/worker.yaml
Normal file
67
apps/wcag-ada/k8s/worker.yaml
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: wcag-ada-worker
|
||||||
|
namespace: wcag-ada
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: wcag-ada-worker
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: wcag-ada-worker
|
||||||
|
spec:
|
||||||
|
initContainers:
|
||||||
|
- name: wait-for-services
|
||||||
|
image: busybox:1.36
|
||||||
|
command: ['sh', '-c', 'until nc -z postgres-service 5432 && nc -z redis-service 6379 && nc -z wcag-ada-api-service 3001; do echo waiting for services; sleep 2; done']
|
||||||
|
containers:
|
||||||
|
- name: worker
|
||||||
|
image: wcag-ada/worker:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 3002
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: wcag-ada-config
|
||||||
|
- secretRef:
|
||||||
|
name: wcag-ada-secrets
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3002
|
||||||
|
initialDelaySeconds: 40
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3002
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "250m"
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
cpu: "1000m"
|
||||||
|
securityContext:
|
||||||
|
capabilities:
|
||||||
|
add:
|
||||||
|
- SYS_ADMIN
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: wcag-ada-worker-service
|
||||||
|
namespace: wcag-ada
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: wcag-ada-worker
|
||||||
|
ports:
|
||||||
|
- port: 3002
|
||||||
|
targetPort: 3002
|
||||||
|
type: ClusterIP
|
||||||
32
apps/wcag-ada/package.json
Normal file
32
apps/wcag-ada/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "@wcag-ada/root",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo run dev --parallel",
|
||||||
|
"build": "turbo run build",
|
||||||
|
"test": "turbo run test",
|
||||||
|
"lint": "turbo run lint",
|
||||||
|
"typecheck": "turbo run typecheck",
|
||||||
|
"clean": "turbo run clean",
|
||||||
|
"dev:api": "cd api && bun run dev",
|
||||||
|
"dev:worker": "cd worker && bun run dev",
|
||||||
|
"dev:dashboard": "cd dashboard && bun run dev",
|
||||||
|
"dev:scanner": "cd scanner && bun run example",
|
||||||
|
"migrate": "cd api && bunx prisma migrate dev",
|
||||||
|
"db:push": "cd api && bunx prisma db push",
|
||||||
|
"db:studio": "cd api && bunx prisma studio",
|
||||||
|
"db:seed": "cd api && bun run seed"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "latest"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"api",
|
||||||
|
"config",
|
||||||
|
"dashboard",
|
||||||
|
"scanner",
|
||||||
|
"shared",
|
||||||
|
"worker"
|
||||||
|
]
|
||||||
|
}
|
||||||
162
apps/wcag-ada/scanner/README.md
Normal file
162
apps/wcag-ada/scanner/README.md
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
# WCAG-ADA Accessibility Scanner
|
||||||
|
|
||||||
|
A high-performance accessibility scanner built with Playwright and axe-core for WCAG compliance testing.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🚀 Fast scanning with Playwright
|
||||||
|
- ♿ WCAG 2.0, 2.1, and 2.2 compliance testing
|
||||||
|
- 📊 Detailed violation reports with fix suggestions
|
||||||
|
- 📸 Screenshot capture for violations
|
||||||
|
- 🔐 Authentication support (basic, form-based, custom)
|
||||||
|
- 📱 Mobile and desktop viewport testing
|
||||||
|
- 🎯 Custom rule support
|
||||||
|
- 🚫 Resource blocking for faster scans
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AccessibilityScanner } from '@wcag-ada/scanner';
|
||||||
|
|
||||||
|
const scanner = new AccessibilityScanner();
|
||||||
|
await scanner.initialize();
|
||||||
|
|
||||||
|
const result = await scanner.scan({
|
||||||
|
url: 'https://example.com',
|
||||||
|
wcagLevel: {
|
||||||
|
level: 'AA',
|
||||||
|
version: '2.1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Compliance Score: ${result.summary.score}%`);
|
||||||
|
console.log(`Violations Found: ${result.summary.violationCount}`);
|
||||||
|
|
||||||
|
await scanner.close();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await scanner.scan({
|
||||||
|
url: 'https://example.com',
|
||||||
|
wcagLevel: {
|
||||||
|
level: 'AA',
|
||||||
|
version: '2.1'
|
||||||
|
},
|
||||||
|
includeScreenshots: true,
|
||||||
|
viewport: {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
isMobile: false
|
||||||
|
},
|
||||||
|
authenticate: {
|
||||||
|
type: 'form',
|
||||||
|
loginUrl: 'https://example.com/login',
|
||||||
|
credentials: {
|
||||||
|
username: 'user@example.com',
|
||||||
|
password: 'password'
|
||||||
|
},
|
||||||
|
selectors: {
|
||||||
|
username: '#email',
|
||||||
|
password: '#password',
|
||||||
|
submit: 'button[type="submit"]'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
excludeSelectors: [
|
||||||
|
'.cookie-banner',
|
||||||
|
'[aria-hidden="true"]'
|
||||||
|
],
|
||||||
|
waitForSelector: '#main-content',
|
||||||
|
timeout: 60000
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scan Multiple Pages
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const urls = [
|
||||||
|
'https://example.com',
|
||||||
|
'https://example.com/about',
|
||||||
|
'https://example.com/contact'
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await scanner.scanMultiplePages(urls, {
|
||||||
|
wcagLevel: { level: 'AA', version: '2.1' }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scan Results
|
||||||
|
|
||||||
|
The scanner returns a comprehensive `AccessibilityScanResult` object containing:
|
||||||
|
|
||||||
|
- **Summary**: Overall compliance score, violation counts by severity
|
||||||
|
- **Violations**: Detailed list of accessibility issues with:
|
||||||
|
- Impact level (critical, serious, moderate, minor)
|
||||||
|
- Affected elements with HTML snippets
|
||||||
|
- Fix suggestions
|
||||||
|
- WCAG criteria mapping
|
||||||
|
- Screenshots (if enabled)
|
||||||
|
- **Passes**: Rules that passed
|
||||||
|
- **Incomplete**: Rules that need manual review
|
||||||
|
- **Page Metadata**: Title, language, element counts, landmark presence
|
||||||
|
- **WCAG Compliance**: Level-specific compliance status
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
The scanner is optimized for speed:
|
||||||
|
- Blocks unnecessary resources (images, fonts, analytics)
|
||||||
|
- Parallel page scanning support
|
||||||
|
- Configurable timeouts
|
||||||
|
- Headless browser mode
|
||||||
|
|
||||||
|
## Custom Rules
|
||||||
|
|
||||||
|
Add custom accessibility rules:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const customRules = [{
|
||||||
|
id: 'custom-button-size',
|
||||||
|
selector: 'button',
|
||||||
|
tags: ['custom', 'wcag21aa'],
|
||||||
|
description: 'Buttons must have minimum touch target size',
|
||||||
|
help: 'Ensure buttons are at least 44x44 pixels',
|
||||||
|
severity: 'serious',
|
||||||
|
validator: (element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.width >= 44 && rect.height >= 44;
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
const result = await scanner.scan({
|
||||||
|
url: 'https://example.com',
|
||||||
|
customRules
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/example.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Watch mode
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
29
apps/wcag-ada/scanner/package.json
Normal file
29
apps/wcag-ada/scanner/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "@wcag-ada/scanner",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b",
|
||||||
|
"dev": "tsc -w",
|
||||||
|
"test": "playwright test",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@axe-core/playwright": "^4.8.3",
|
||||||
|
"@playwright/test": "^1.40.1",
|
||||||
|
"axe-core": "^4.8.3",
|
||||||
|
"playwright": "^1.40.1",
|
||||||
|
"pdfkit": "^0.14.0",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
|
"p-queue": "^7.4.1",
|
||||||
|
"@wcag-ada/shared": "workspace:*",
|
||||||
|
"@wcag-ada/config": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.5",
|
||||||
|
"@types/pdfkit": "^0.13.4",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
285
apps/wcag-ada/scanner/src/core/accessibility-browser.ts
Normal file
285
apps/wcag-ada/scanner/src/core/accessibility-browser.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
import { chromium, Browser, BrowserContext, Page } from 'playwright';
|
||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import type { AxeResults } from 'axe-core';
|
||||||
|
import type {
|
||||||
|
AccessibilityScanOptions,
|
||||||
|
ViewportSize,
|
||||||
|
AuthenticationConfig
|
||||||
|
} from '@wcag-ada/shared';
|
||||||
|
import { getWCAGTags, RESOURCE_BLOCK_PATTERNS } from '@wcag-ada/shared';
|
||||||
|
import { getScannerConfig, type ScannerConfig } from '@wcag-ada/config';
|
||||||
|
|
||||||
|
export interface AccessibilityBrowserOptions {
|
||||||
|
headless?: boolean;
|
||||||
|
scanTimeout?: number;
|
||||||
|
defaultViewport?: ViewportSize;
|
||||||
|
blockResources?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AccessibilityBrowser {
|
||||||
|
private browser?: Browser;
|
||||||
|
private context?: BrowserContext;
|
||||||
|
private logger: any;
|
||||||
|
private config: ScannerConfig;
|
||||||
|
private options: AccessibilityBrowserOptions;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
constructor(logger?: any, options?: AccessibilityBrowserOptions) {
|
||||||
|
this.logger = logger || console;
|
||||||
|
this.config = getScannerConfig();
|
||||||
|
|
||||||
|
// Merge config with provided options
|
||||||
|
this.options = {
|
||||||
|
headless: options?.headless ?? this.config.headless,
|
||||||
|
scanTimeout: options?.scanTimeout ?? this.config.timeout,
|
||||||
|
defaultViewport: options?.defaultViewport ?? this.config.viewport,
|
||||||
|
blockResources: options?.blockResources ?? this.config.blockResources,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
this.browser = await chromium.launch({
|
||||||
|
headless: this.options.headless,
|
||||||
|
args: this.config.browsers.chromium.args,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.context = await this.browser.newContext({
|
||||||
|
viewport: this.options.defaultViewport,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Block resources for faster scanning if enabled
|
||||||
|
if (this.options.blockResources) {
|
||||||
|
await this.context.route('**/*', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
const shouldBlock = RESOURCE_BLOCK_PATTERNS.some(pattern => {
|
||||||
|
if (pattern.includes('*')) {
|
||||||
|
const regex = new RegExp(pattern.replace('*', '.*'));
|
||||||
|
return regex.test(url);
|
||||||
|
}
|
||||||
|
return url.includes(pattern);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldBlock) {
|
||||||
|
route.abort();
|
||||||
|
} else {
|
||||||
|
route.continue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.context) await this.context.close();
|
||||||
|
if (this.browser) await this.browser.close();
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async scanPage(options: AccessibilityScanOptions): Promise<{
|
||||||
|
page: Page;
|
||||||
|
axeResults: AxeResults;
|
||||||
|
html: string;
|
||||||
|
}> {
|
||||||
|
if (!this.context) throw new Error('Browser not initialized');
|
||||||
|
|
||||||
|
const { url, viewport, authenticate, wcagLevel, excludeSelectors, waitForSelector } = options;
|
||||||
|
|
||||||
|
const page = await this.context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set viewport if different from default
|
||||||
|
if (viewport) {
|
||||||
|
await page.setViewportSize({
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle authentication if needed
|
||||||
|
if (authenticate) {
|
||||||
|
await this.handleAuthentication(page, authenticate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to URL
|
||||||
|
await page.goto(url, {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: options.timeout || this.config.pageLoadTimeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for specific selector if provided
|
||||||
|
if (waitForSelector) {
|
||||||
|
await page.waitForSelector(waitForSelector, {
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional wait for dynamic content
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Get page HTML
|
||||||
|
const html = await page.content();
|
||||||
|
|
||||||
|
// Configure axe
|
||||||
|
const axeBuilder = new AxeBuilder({ page });
|
||||||
|
|
||||||
|
// Set WCAG tags
|
||||||
|
if (wcagLevel) {
|
||||||
|
const tags = getWCAGTags(wcagLevel);
|
||||||
|
axeBuilder.withTags(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude selectors if provided
|
||||||
|
if (excludeSelectors && excludeSelectors.length > 0) {
|
||||||
|
excludeSelectors.forEach(selector => {
|
||||||
|
axeBuilder.exclude(selector);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set options
|
||||||
|
axeBuilder.options({
|
||||||
|
resultTypes: this.config.axe.resultTypes,
|
||||||
|
elementRef: true,
|
||||||
|
runOnly: {
|
||||||
|
type: 'tag',
|
||||||
|
values: wcagLevel ? getWCAGTags(wcagLevel) : this.config.axe.tags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run accessibility scan
|
||||||
|
const axeResults = await Promise.race([
|
||||||
|
axeBuilder.analyze(),
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Scan timeout')), this.options.scanTimeout!)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
axeResults,
|
||||||
|
html,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await page.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAuthentication(page: Page, auth: AuthenticationConfig): Promise<void> {
|
||||||
|
switch (auth.type) {
|
||||||
|
case 'basic':
|
||||||
|
if (auth.credentials?.username && auth.credentials?.password) {
|
||||||
|
await page.context().setHTTPCredentials({
|
||||||
|
username: auth.credentials.username,
|
||||||
|
password: auth.credentials.password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'form':
|
||||||
|
if (auth.loginUrl) {
|
||||||
|
await page.goto(auth.loginUrl);
|
||||||
|
|
||||||
|
if (auth.selectors?.username && auth.credentials?.username) {
|
||||||
|
await page.fill(auth.selectors.username, auth.credentials.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.selectors?.password && auth.credentials?.password) {
|
||||||
|
await page.fill(auth.selectors.password, auth.credentials.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.selectors?.submit) {
|
||||||
|
await page.click(auth.selectors.submit);
|
||||||
|
await page.waitForNavigation({ waitUntil: 'networkidle' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'custom':
|
||||||
|
if (auth.customScript) {
|
||||||
|
await page.evaluate(auth.customScript);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async captureViolationScreenshots(
|
||||||
|
page: Page,
|
||||||
|
violations: AxeResults['violations']
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const screenshots = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const violation of violations) {
|
||||||
|
for (let i = 0; i < violation.nodes.length; i++) {
|
||||||
|
const node = violation.nodes[i];
|
||||||
|
if (node.target && node.target.length > 0) {
|
||||||
|
try {
|
||||||
|
const selector = node.target[0];
|
||||||
|
const element = await page.$(selector);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
const screenshot = await element.screenshot({
|
||||||
|
type: 'png',
|
||||||
|
});
|
||||||
|
|
||||||
|
const key = `${violation.id}_${i}`;
|
||||||
|
screenshots.set(key, screenshot.toString('base64'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger?.warn(`Failed to capture screenshot for ${violation.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return screenshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluateCustomRules(page: Page, customRules: any[]): Promise<any[]> {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const rule of customRules) {
|
||||||
|
try {
|
||||||
|
const elements = await page.$$(rule.selector);
|
||||||
|
const violations = [];
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
const passes = await page.evaluate(
|
||||||
|
(el, validatorStr) => {
|
||||||
|
const validator = new Function('element', `return (${validatorStr})(element)`);
|
||||||
|
return validator(el);
|
||||||
|
},
|
||||||
|
element,
|
||||||
|
rule.validator.toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!passes) {
|
||||||
|
const html = await element.evaluate(el => el.outerHTML);
|
||||||
|
violations.push({
|
||||||
|
html,
|
||||||
|
target: [rule.selector],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.length > 0) {
|
||||||
|
results.push({
|
||||||
|
id: rule.id,
|
||||||
|
description: rule.description,
|
||||||
|
help: rule.help,
|
||||||
|
helpUrl: rule.helpUrl,
|
||||||
|
impact: rule.severity,
|
||||||
|
tags: rule.tags,
|
||||||
|
nodes: violations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger?.error(`Failed to evaluate custom rule ${rule.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
303
apps/wcag-ada/scanner/src/core/scanner.ts
Normal file
303
apps/wcag-ada/scanner/src/core/scanner.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
import { AccessibilityBrowser } from './accessibility-browser';
|
||||||
|
import type {
|
||||||
|
AccessibilityScanOptions,
|
||||||
|
AccessibilityScanResult,
|
||||||
|
AccessibilityViolation,
|
||||||
|
ScanSummary,
|
||||||
|
PageMetadata,
|
||||||
|
WCAGCompliance,
|
||||||
|
WCAGLevel,
|
||||||
|
CriteriaResult
|
||||||
|
} from '@wcag-ada/shared';
|
||||||
|
import {
|
||||||
|
calculateComplianceScore,
|
||||||
|
summarizeViolations,
|
||||||
|
generateFixSuggestion,
|
||||||
|
extractPageMetadata,
|
||||||
|
isCompliantWithLevel,
|
||||||
|
CRITICAL_WCAG_CRITERIA
|
||||||
|
} from '@wcag-ada/shared';
|
||||||
|
import type { AxeResults, Result as AxeResult } from 'axe-core';
|
||||||
|
import { initializeWcagConfig } from '@wcag-ada/config';
|
||||||
|
|
||||||
|
export class AccessibilityScanner {
|
||||||
|
private browser: AccessibilityBrowser;
|
||||||
|
private logger: any;
|
||||||
|
|
||||||
|
constructor(logger?: any) {
|
||||||
|
this.logger = logger || console;
|
||||||
|
|
||||||
|
// Initialize config for scanner service
|
||||||
|
initializeWcagConfig();
|
||||||
|
|
||||||
|
this.browser = new AccessibilityBrowser(logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
await this.browser.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async scan(options: AccessibilityScanOptions): Promise<AccessibilityScanResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Perform the scan
|
||||||
|
const { page, axeResults, html } = await this.browser.scanPage(options);
|
||||||
|
|
||||||
|
// Capture screenshots if requested
|
||||||
|
let screenshotMap: Map<string, string> | undefined;
|
||||||
|
if (options.includeScreenshots) {
|
||||||
|
screenshotMap = await this.browser.captureViolationScreenshots(page, axeResults.violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate custom rules if provided
|
||||||
|
let customViolations: AxeResult[] = [];
|
||||||
|
if (options.customRules && options.customRules.length > 0) {
|
||||||
|
customViolations = await this.browser.evaluateCustomRules(page, options.customRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the page
|
||||||
|
await page.close();
|
||||||
|
|
||||||
|
// Process results
|
||||||
|
const violations = this.processViolations(
|
||||||
|
[...axeResults.violations, ...customViolations],
|
||||||
|
screenshotMap
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract metadata
|
||||||
|
const pageMetadata = this.extractPageMetadata(html, options);
|
||||||
|
|
||||||
|
// Calculate summary
|
||||||
|
const summary = this.calculateSummary(axeResults, violations);
|
||||||
|
|
||||||
|
// Determine WCAG compliance
|
||||||
|
const wcagCompliance = this.calculateWCAGCompliance(
|
||||||
|
violations,
|
||||||
|
axeResults.passes,
|
||||||
|
options.wcagLevel || { level: 'AA', version: '2.1' },
|
||||||
|
summary.score
|
||||||
|
);
|
||||||
|
|
||||||
|
const scanDuration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: options.url,
|
||||||
|
timestamp: new Date(),
|
||||||
|
scanDuration,
|
||||||
|
summary,
|
||||||
|
violations,
|
||||||
|
passes: axeResults.passes,
|
||||||
|
incomplete: axeResults.incomplete,
|
||||||
|
inapplicable: axeResults.inapplicable,
|
||||||
|
pageMetadata,
|
||||||
|
wcagCompliance,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Scan failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private processViolations(
|
||||||
|
axeViolations: AxeResult[],
|
||||||
|
screenshots?: Map<string, string>
|
||||||
|
): AccessibilityViolation[] {
|
||||||
|
return axeViolations.map(violation => {
|
||||||
|
const nodes = violation.nodes.map((node, index) => {
|
||||||
|
const screenshotKey = `${violation.id}_${index}`;
|
||||||
|
return {
|
||||||
|
html: node.html,
|
||||||
|
target: node.target as string[],
|
||||||
|
xpath: node.xpath?.[0],
|
||||||
|
failureSummary: node.failureSummary,
|
||||||
|
screenshot: screenshots?.get(screenshotKey),
|
||||||
|
relatedNodes: node.relatedNodes?.map(related => ({
|
||||||
|
html: related.html,
|
||||||
|
target: related.target as string[],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: violation.id,
|
||||||
|
impact: violation.impact!,
|
||||||
|
description: violation.description,
|
||||||
|
help: violation.help,
|
||||||
|
helpUrl: violation.helpUrl,
|
||||||
|
tags: violation.tags,
|
||||||
|
nodes,
|
||||||
|
wcagCriteria: this.mapToWCAGCriteria(violation.tags),
|
||||||
|
fixSuggestion: generateFixSuggestion(violation.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractPageMetadata(html: string, options: AccessibilityScanOptions): PageMetadata {
|
||||||
|
const metadata = extractPageMetadata(html);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: metadata.title || 'Untitled Page',
|
||||||
|
url: options.url,
|
||||||
|
viewport: options.viewport || { width: 1280, height: 720 },
|
||||||
|
language: metadata.language,
|
||||||
|
doctype: this.extractDoctype(html),
|
||||||
|
hasLandmarks: metadata.hasLandmarks || false,
|
||||||
|
hasHeadings: metadata.hasHeadings || false,
|
||||||
|
imagesCount: metadata.imagesCount || 0,
|
||||||
|
formsCount: metadata.formsCount || 0,
|
||||||
|
linksCount: metadata.linksCount || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractDoctype(html: string): string {
|
||||||
|
const doctypeMatch = html.match(/<!DOCTYPE\s+([^>]+)>/i);
|
||||||
|
return doctypeMatch ? doctypeMatch[1].trim() : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateSummary(
|
||||||
|
axeResults: AxeResults,
|
||||||
|
violations: AccessibilityViolation[]
|
||||||
|
): ScanSummary {
|
||||||
|
const violationSummary = summarizeViolations(violations);
|
||||||
|
const violationCount = violations.reduce((sum, v) => sum + v.nodes.length, 0);
|
||||||
|
|
||||||
|
const result: AccessibilityScanResult = {
|
||||||
|
violations,
|
||||||
|
passes: axeResults.passes,
|
||||||
|
summary: {} as ScanSummary,
|
||||||
|
} as AccessibilityScanResult;
|
||||||
|
|
||||||
|
const score = calculateComplianceScore(result);
|
||||||
|
|
||||||
|
return {
|
||||||
|
violationCount,
|
||||||
|
passCount: axeResults.passes.length,
|
||||||
|
incompleteCount: axeResults.incomplete.length,
|
||||||
|
inapplicableCount: axeResults.inapplicable.length,
|
||||||
|
...violationSummary,
|
||||||
|
score,
|
||||||
|
} as ScanSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateWCAGCompliance(
|
||||||
|
violations: AccessibilityViolation[],
|
||||||
|
passes: AxeResult[],
|
||||||
|
wcagLevel: WCAGLevel,
|
||||||
|
score: number
|
||||||
|
): WCAGCompliance {
|
||||||
|
const criteriaMap = new Map<string, CriteriaResult>();
|
||||||
|
|
||||||
|
// Initialize critical criteria
|
||||||
|
CRITICAL_WCAG_CRITERIA.forEach(criterion => {
|
||||||
|
criteriaMap.set(criterion, {
|
||||||
|
criterion,
|
||||||
|
level: this.getCriterionLevel(criterion),
|
||||||
|
passed: true,
|
||||||
|
violations: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process violations
|
||||||
|
violations.forEach(violation => {
|
||||||
|
violation.wcagCriteria?.forEach(criterion => {
|
||||||
|
if (!criteriaMap.has(criterion)) {
|
||||||
|
criteriaMap.set(criterion, {
|
||||||
|
criterion,
|
||||||
|
level: this.getCriterionLevel(criterion),
|
||||||
|
passed: false,
|
||||||
|
violations: [violation.id],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const result = criteriaMap.get(criterion)!;
|
||||||
|
result.passed = false;
|
||||||
|
result.violations.push(violation.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const criteriaResults = Array.from(criteriaMap.values());
|
||||||
|
const isCompliant = isCompliantWithLevel(score, wcagLevel.level);
|
||||||
|
|
||||||
|
return {
|
||||||
|
level: wcagLevel,
|
||||||
|
isCompliant,
|
||||||
|
criteriaResults,
|
||||||
|
overallScore: score,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToWCAGCriteria(tags: string[]): string[] {
|
||||||
|
const criteriaMap: Record<string, string[]> = {
|
||||||
|
'wcag111': ['1.1.1'],
|
||||||
|
'wcag131': ['1.3.1'],
|
||||||
|
'wcag141': ['1.4.1'],
|
||||||
|
'wcag143': ['1.4.3'],
|
||||||
|
'wcag211': ['2.1.1'],
|
||||||
|
'wcag212': ['2.1.2'],
|
||||||
|
'wcag241': ['2.4.1'],
|
||||||
|
'wcag242': ['2.4.2'],
|
||||||
|
'wcag243': ['2.4.3'],
|
||||||
|
'wcag244': ['2.4.4'],
|
||||||
|
'wcag311': ['3.1.1'],
|
||||||
|
'wcag331': ['3.3.1'],
|
||||||
|
'wcag332': ['3.3.2'],
|
||||||
|
'wcag411': ['4.1.1'],
|
||||||
|
'wcag412': ['4.1.2'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const criteria = new Set<string>();
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const mapped = criteriaMap[tag];
|
||||||
|
if (mapped) {
|
||||||
|
mapped.forEach(c => criteria.add(c));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(criteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCriterionLevel(criterion: string): 'A' | 'AA' | 'AAA' {
|
||||||
|
const levelMap: Record<string, 'A' | 'AA' | 'AAA'> = {
|
||||||
|
'1.1.1': 'A',
|
||||||
|
'1.3.1': 'A',
|
||||||
|
'1.4.1': 'A',
|
||||||
|
'1.4.3': 'AA',
|
||||||
|
'2.1.1': 'A',
|
||||||
|
'2.1.2': 'A',
|
||||||
|
'2.4.1': 'A',
|
||||||
|
'2.4.2': 'A',
|
||||||
|
'2.4.3': 'A',
|
||||||
|
'2.4.4': 'A',
|
||||||
|
'3.1.1': 'A',
|
||||||
|
'3.3.1': 'A',
|
||||||
|
'3.3.2': 'A',
|
||||||
|
'4.1.1': 'A',
|
||||||
|
'4.1.2': 'A',
|
||||||
|
};
|
||||||
|
|
||||||
|
return levelMap[criterion] || 'A';
|
||||||
|
}
|
||||||
|
|
||||||
|
async scanMultiplePages(
|
||||||
|
urls: string[],
|
||||||
|
baseOptions: Omit<AccessibilityScanOptions, 'url'>
|
||||||
|
): Promise<AccessibilityScanResult[]> {
|
||||||
|
const results: AccessibilityScanResult[] = [];
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
const result = await this.scan({ ...baseOptions, url });
|
||||||
|
results.push(result);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to scan ${url}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
apps/wcag-ada/scanner/src/example.ts
Normal file
79
apps/wcag-ada/scanner/src/example.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { AccessibilityScanner } from './core/scanner';
|
||||||
|
import type { AccessibilityScanOptions } from '@wcag-ada/shared';
|
||||||
|
import { createLogger } from '@lib/service/core-logger';
|
||||||
|
|
||||||
|
const logger = createLogger('scanner-example');
|
||||||
|
|
||||||
|
async function runExample() {
|
||||||
|
const scanner = new AccessibilityScanner();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize the browser
|
||||||
|
await scanner.initialize();
|
||||||
|
|
||||||
|
// Define scan options
|
||||||
|
const scanOptions: AccessibilityScanOptions = {
|
||||||
|
url: 'https://example.com',
|
||||||
|
wcagLevel: {
|
||||||
|
level: 'AA',
|
||||||
|
version: '2.1'
|
||||||
|
},
|
||||||
|
includeScreenshots: true,
|
||||||
|
viewport: {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080
|
||||||
|
},
|
||||||
|
excludeSelectors: [
|
||||||
|
'.cookie-banner',
|
||||||
|
'[aria-hidden="true"]'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('Starting accessibility scan...');
|
||||||
|
const result = await scanner.scan(scanOptions);
|
||||||
|
|
||||||
|
// Display summary
|
||||||
|
logger.info('\n=== SCAN SUMMARY ===');
|
||||||
|
logger.info(`URL: ${result.url}`);
|
||||||
|
logger.info(`Compliance Score: ${result.summary.score}%`);
|
||||||
|
logger.info(`WCAG ${result.wcagCompliance.level.version} Level ${result.wcagCompliance.level.level}: ${result.wcagCompliance.isCompliant ? 'PASSED' : 'FAILED'}`);
|
||||||
|
logger.info(`\nViolations: ${result.summary.violationCount}`);
|
||||||
|
logger.info(`- Critical: ${result.summary.criticalIssues}`);
|
||||||
|
logger.info(`- Serious: ${result.summary.seriousIssues}`);
|
||||||
|
logger.info(`- Moderate: ${result.summary.moderateIssues}`);
|
||||||
|
logger.info(`- Minor: ${result.summary.minorIssues}`);
|
||||||
|
|
||||||
|
// Display top violations
|
||||||
|
if (result.violations.length > 0) {
|
||||||
|
logger.info('\n=== TOP VIOLATIONS ===');
|
||||||
|
result.violations.slice(0, 5).forEach((violation, index) => {
|
||||||
|
logger.info(`\n${index + 1}. ${violation.help}`);
|
||||||
|
logger.info(` Impact: ${violation.impact}`);
|
||||||
|
logger.info(` Affected elements: ${violation.nodes.length}`);
|
||||||
|
logger.info(` Fix: ${violation.fixSuggestion}`);
|
||||||
|
logger.info(` More info: ${violation.helpUrl}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display page metadata
|
||||||
|
logger.info('\n=== PAGE METADATA ===');
|
||||||
|
logger.info(`Title: ${result.pageMetadata.title}`);
|
||||||
|
logger.info(`Language: ${result.pageMetadata.language || 'Not specified'}`);
|
||||||
|
logger.info(`Images: ${result.pageMetadata.imagesCount}`);
|
||||||
|
logger.info(`Forms: ${result.pageMetadata.formsCount}`);
|
||||||
|
logger.info(`Links: ${result.pageMetadata.linksCount}`);
|
||||||
|
logger.info(`Has landmarks: ${result.pageMetadata.hasLandmarks ? 'Yes' : 'No'}`);
|
||||||
|
logger.info(`Has headings: ${result.pageMetadata.hasHeadings ? 'Yes' : 'No'}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Scan failed:', error);
|
||||||
|
} finally {
|
||||||
|
// Always close the browser
|
||||||
|
await scanner.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the example
|
||||||
|
if (require.main === module) {
|
||||||
|
runExample().catch((error) => logger.error('Example failed:', error));
|
||||||
|
}
|
||||||
14
apps/wcag-ada/scanner/src/index.ts
Normal file
14
apps/wcag-ada/scanner/src/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
export { AccessibilityScanner } from './core/scanner';
|
||||||
|
export { AccessibilityBrowser } from './core/accessibility-browser';
|
||||||
|
export type { AccessibilityBrowserOptions } from './core/accessibility-browser';
|
||||||
|
|
||||||
|
// Re-export shared types for convenience
|
||||||
|
export type {
|
||||||
|
AccessibilityScanOptions,
|
||||||
|
AccessibilityScanResult,
|
||||||
|
AccessibilityViolation,
|
||||||
|
WCAGLevel,
|
||||||
|
Website,
|
||||||
|
ScanJob,
|
||||||
|
ComplianceReport,
|
||||||
|
} from '@wcag-ada/shared';
|
||||||
13
apps/wcag-ada/scanner/tsconfig.json
Normal file
13
apps/wcag-ada/scanner/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../shared" }
|
||||||
|
]
|
||||||
|
}
|
||||||
41
apps/wcag-ada/scripts/build-images.sh
Executable file
41
apps/wcag-ada/scripts/build-images.sh
Executable file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Script to build all Docker images locally
|
||||||
|
|
||||||
|
echo "Building WCAG-ADA Docker images..."
|
||||||
|
|
||||||
|
# Get the repository root (two levels up from scripts directory)
|
||||||
|
REPO_ROOT=$(cd "$(dirname "$0")/../../.." && pwd)
|
||||||
|
WCAG_ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
|
||||||
|
# Set default registry if not provided
|
||||||
|
REGISTRY=${DOCKER_REGISTRY:-"wcag-ada"}
|
||||||
|
|
||||||
|
# Build API image
|
||||||
|
echo "Building API image..."
|
||||||
|
docker build \
|
||||||
|
-f "$WCAG_ROOT/api/Dockerfile" \
|
||||||
|
-t "$REGISTRY/api:latest" \
|
||||||
|
"$REPO_ROOT"
|
||||||
|
|
||||||
|
# Build Worker image
|
||||||
|
echo "Building Worker image..."
|
||||||
|
docker build \
|
||||||
|
-f "$WCAG_ROOT/worker/Dockerfile" \
|
||||||
|
-t "$REGISTRY/worker:latest" \
|
||||||
|
"$REPO_ROOT"
|
||||||
|
|
||||||
|
# Build Dashboard image
|
||||||
|
echo "Building Dashboard image..."
|
||||||
|
docker build \
|
||||||
|
-f "$WCAG_ROOT/dashboard/Dockerfile" \
|
||||||
|
-t "$REGISTRY/dashboard:latest" \
|
||||||
|
"$REPO_ROOT"
|
||||||
|
|
||||||
|
echo "All images built successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "Images created:"
|
||||||
|
echo " - $REGISTRY/api:latest"
|
||||||
|
echo " - $REGISTRY/worker:latest"
|
||||||
|
echo " - $REGISTRY/dashboard:latest"
|
||||||
71
apps/wcag-ada/scripts/deploy-k8s.sh
Executable file
71
apps/wcag-ada/scripts/deploy-k8s.sh
Executable file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Script to deploy to Kubernetes
|
||||||
|
|
||||||
|
NAMESPACE=${NAMESPACE:-"wcag-ada"}
|
||||||
|
ENVIRONMENT=${ENVIRONMENT:-"staging"}
|
||||||
|
|
||||||
|
echo "Deploying WCAG-ADA to Kubernetes..."
|
||||||
|
echo "Namespace: $NAMESPACE"
|
||||||
|
echo "Environment: $ENVIRONMENT"
|
||||||
|
|
||||||
|
# Get the script directory
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
K8S_DIR="$SCRIPT_DIR/../k8s"
|
||||||
|
|
||||||
|
# Create namespace if it doesn't exist
|
||||||
|
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
|
# Apply configurations
|
||||||
|
echo "Applying configurations..."
|
||||||
|
kubectl apply -f "$K8S_DIR/configmap.yaml" -n $NAMESPACE
|
||||||
|
|
||||||
|
# Apply secrets (only if file exists)
|
||||||
|
if [ -f "$K8S_DIR/secrets-$ENVIRONMENT.yaml" ]; then
|
||||||
|
echo "Applying environment-specific secrets..."
|
||||||
|
kubectl apply -f "$K8S_DIR/secrets-$ENVIRONMENT.yaml" -n $NAMESPACE
|
||||||
|
else
|
||||||
|
echo "Warning: No environment-specific secrets found. Using default secrets."
|
||||||
|
kubectl apply -f "$K8S_DIR/secrets.yaml" -n $NAMESPACE
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deploy services
|
||||||
|
echo "Deploying PostgreSQL..."
|
||||||
|
kubectl apply -f "$K8S_DIR/postgres.yaml" -n $NAMESPACE
|
||||||
|
|
||||||
|
echo "Deploying Redis..."
|
||||||
|
kubectl apply -f "$K8S_DIR/redis.yaml" -n $NAMESPACE
|
||||||
|
|
||||||
|
# Wait for databases to be ready
|
||||||
|
echo "Waiting for databases to be ready..."
|
||||||
|
kubectl wait --for=condition=ready pod -l app=postgres -n $NAMESPACE --timeout=300s
|
||||||
|
kubectl wait --for=condition=ready pod -l app=redis -n $NAMESPACE --timeout=300s
|
||||||
|
|
||||||
|
# Deploy applications
|
||||||
|
echo "Deploying API..."
|
||||||
|
kubectl apply -f "$K8S_DIR/api.yaml" -n $NAMESPACE
|
||||||
|
|
||||||
|
echo "Deploying Worker..."
|
||||||
|
kubectl apply -f "$K8S_DIR/worker.yaml" -n $NAMESPACE
|
||||||
|
|
||||||
|
echo "Deploying Dashboard..."
|
||||||
|
kubectl apply -f "$K8S_DIR/dashboard.yaml" -n $NAMESPACE
|
||||||
|
|
||||||
|
# Apply ingress
|
||||||
|
echo "Applying Ingress rules..."
|
||||||
|
kubectl apply -f "$K8S_DIR/ingress.yaml" -n $NAMESPACE
|
||||||
|
|
||||||
|
# Wait for deployments
|
||||||
|
echo "Waiting for deployments to be ready..."
|
||||||
|
kubectl rollout status deployment/wcag-ada-api -n $NAMESPACE
|
||||||
|
kubectl rollout status deployment/wcag-ada-worker -n $NAMESPACE
|
||||||
|
kubectl rollout status deployment/wcag-ada-dashboard -n $NAMESPACE
|
||||||
|
|
||||||
|
echo "Deployment complete!"
|
||||||
|
echo ""
|
||||||
|
echo "Services deployed:"
|
||||||
|
kubectl get services -n $NAMESPACE
|
||||||
|
echo ""
|
||||||
|
echo "Pods status:"
|
||||||
|
kubectl get pods -n $NAMESPACE
|
||||||
52
apps/wcag-ada/scripts/local-dev.sh
Executable file
52
apps/wcag-ada/scripts/local-dev.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Script to set up local development environment
|
||||||
|
|
||||||
|
echo "Setting up WCAG-ADA local development environment..."
|
||||||
|
|
||||||
|
# Get the script directory
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
WCAG_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
|
||||||
|
|
||||||
|
# Check if .env exists
|
||||||
|
if [ ! -f "$WCAG_ROOT/.env" ]; then
|
||||||
|
echo "Creating .env file from template..."
|
||||||
|
cp "$WCAG_ROOT/.env.example" "$WCAG_ROOT/.env"
|
||||||
|
echo "Please edit .env file with your configuration values"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start development databases
|
||||||
|
echo "Starting development databases..."
|
||||||
|
cd "$WCAG_ROOT"
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Wait for databases to be ready
|
||||||
|
echo "Waiting for databases..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
echo "Running database migrations..."
|
||||||
|
cd "$WCAG_ROOT/api"
|
||||||
|
bunx prisma migrate dev
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
echo ""
|
||||||
|
echo "Development environment ready!"
|
||||||
|
echo ""
|
||||||
|
echo "To start all services, run:"
|
||||||
|
echo " cd $WCAG_ROOT && bun run dev"
|
||||||
|
echo ""
|
||||||
|
echo "Or start individual services:"
|
||||||
|
echo " API: cd $WCAG_ROOT/api && bun run dev"
|
||||||
|
echo " Worker: cd $WCAG_ROOT/worker && bun run dev"
|
||||||
|
echo " Dashboard: cd $WCAG_ROOT/dashboard && bun run dev"
|
||||||
|
echo ""
|
||||||
|
echo "Database tools:"
|
||||||
|
echo " Prisma Studio: cd $WCAG_ROOT/api && bunx prisma studio"
|
||||||
|
echo " Redis CLI: docker exec -it wcag-ada-redis-dev redis-cli"
|
||||||
|
echo " PostgreSQL: docker exec -it wcag-ada-postgres-dev psql -U postgres -d wcag_ada_dev"
|
||||||
18
apps/wcag-ada/shared/package.json
Normal file
18
apps/wcag-ada/shared/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "@wcag-ada/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b",
|
||||||
|
"dev": "tsc -w",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axe-core": "^4.8.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.5",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
81
apps/wcag-ada/shared/src/constants.ts
Normal file
81
apps/wcag-ada/shared/src/constants.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
export const WCAG_TAGS = {
|
||||||
|
WCAG_2_0_A: ['wcag2a', 'wcag20a'],
|
||||||
|
WCAG_2_0_AA: ['wcag2aa', 'wcag20aa'],
|
||||||
|
WCAG_2_0_AAA: ['wcag2aaa', 'wcag20aaa'],
|
||||||
|
WCAG_2_1_A: ['wcag21a'],
|
||||||
|
WCAG_2_1_AA: ['wcag21aa'],
|
||||||
|
WCAG_2_1_AAA: ['wcag21aaa'],
|
||||||
|
WCAG_2_2_A: ['wcag22a'],
|
||||||
|
WCAG_2_2_AA: ['wcag22aa'],
|
||||||
|
WCAG_2_2_AAA: ['wcag22aaa'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const IMPACT_SCORES = {
|
||||||
|
critical: 100,
|
||||||
|
serious: 75,
|
||||||
|
moderate: 50,
|
||||||
|
minor: 25,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const COMPLIANCE_THRESHOLDS = {
|
||||||
|
A: 0.95,
|
||||||
|
AA: 0.98,
|
||||||
|
AAA: 1.0,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DEFAULT_VIEWPORT = {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
isMobile: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const MOBILE_VIEWPORT = {
|
||||||
|
width: 375,
|
||||||
|
height: 667,
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
isMobile: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const SCAN_TIMEOUTS = {
|
||||||
|
PAGE_LOAD: 30000,
|
||||||
|
SCAN_EXECUTION: 60000,
|
||||||
|
TOTAL: 120000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const RESOURCE_BLOCK_PATTERNS = [
|
||||||
|
'*.gif',
|
||||||
|
'*.jpg',
|
||||||
|
'*.jpeg',
|
||||||
|
'*.png',
|
||||||
|
'*.svg',
|
||||||
|
'*.webp',
|
||||||
|
'*.woff',
|
||||||
|
'*.woff2',
|
||||||
|
'*.ttf',
|
||||||
|
'*.eot',
|
||||||
|
'fonts.googleapis.com',
|
||||||
|
'fonts.gstatic.com',
|
||||||
|
'googletagmanager.com',
|
||||||
|
'google-analytics.com',
|
||||||
|
'facebook.com',
|
||||||
|
'twitter.com',
|
||||||
|
'doubleclick.net',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CRITICAL_WCAG_CRITERIA = [
|
||||||
|
'1.1.1', // Non-text Content
|
||||||
|
'1.3.1', // Info and Relationships
|
||||||
|
'1.4.3', // Contrast (Minimum)
|
||||||
|
'2.1.1', // Keyboard
|
||||||
|
'2.1.2', // No Keyboard Trap
|
||||||
|
'2.4.1', // Bypass Blocks
|
||||||
|
'2.4.2', // Page Titled
|
||||||
|
'2.4.3', // Focus Order
|
||||||
|
'2.4.4', // Link Purpose
|
||||||
|
'3.1.1', // Language of Page
|
||||||
|
'3.3.1', // Error Identification
|
||||||
|
'3.3.2', // Labels or Instructions
|
||||||
|
'4.1.1', // Parsing
|
||||||
|
'4.1.2', // Name, Role, Value
|
||||||
|
] as const;
|
||||||
3
apps/wcag-ada/shared/src/index.ts
Normal file
3
apps/wcag-ada/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './types';
|
||||||
|
export * from './constants';
|
||||||
|
export * from './utils';
|
||||||
201
apps/wcag-ada/shared/src/types.ts
Normal file
201
apps/wcag-ada/shared/src/types.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import type { Result as AxeResult, ImpactValue, TagValue } from 'axe-core';
|
||||||
|
|
||||||
|
export interface WCAGLevel {
|
||||||
|
level: 'A' | 'AA' | 'AAA';
|
||||||
|
version: '2.0' | '2.1' | '2.2';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessibilityScanOptions {
|
||||||
|
url: string;
|
||||||
|
wcagLevel?: WCAGLevel;
|
||||||
|
viewport?: ViewportSize;
|
||||||
|
authenticate?: AuthenticationConfig;
|
||||||
|
includeScreenshots?: boolean;
|
||||||
|
customRules?: CustomRule[];
|
||||||
|
scanSubpages?: boolean;
|
||||||
|
maxDepth?: number;
|
||||||
|
timeout?: number;
|
||||||
|
waitForSelector?: string;
|
||||||
|
excludeSelectors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewportSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
deviceScaleFactor?: number;
|
||||||
|
isMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticationConfig {
|
||||||
|
type: 'basic' | 'form' | 'oauth' | 'custom';
|
||||||
|
credentials?: {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
loginUrl?: string;
|
||||||
|
selectors?: {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
submit?: string;
|
||||||
|
};
|
||||||
|
customScript?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomRule {
|
||||||
|
id: string;
|
||||||
|
selector: string;
|
||||||
|
tags: string[];
|
||||||
|
description: string;
|
||||||
|
help: string;
|
||||||
|
helpUrl?: string;
|
||||||
|
severity: ImpactValue;
|
||||||
|
validator: (element: Element) => boolean | Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessibilityViolation {
|
||||||
|
id: string;
|
||||||
|
impact: ImpactValue;
|
||||||
|
description: string;
|
||||||
|
help: string;
|
||||||
|
helpUrl: string;
|
||||||
|
tags: TagValue[];
|
||||||
|
nodes: ViolationNode[];
|
||||||
|
wcagCriteria?: string[];
|
||||||
|
fixSuggestion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViolationNode {
|
||||||
|
html: string;
|
||||||
|
target: string[];
|
||||||
|
xpath?: string;
|
||||||
|
failureSummary?: string;
|
||||||
|
screenshot?: string;
|
||||||
|
relatedNodes?: RelatedNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelatedNode {
|
||||||
|
html: string;
|
||||||
|
target: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessibilityScanResult {
|
||||||
|
url: string;
|
||||||
|
timestamp: Date;
|
||||||
|
scanDuration: number;
|
||||||
|
summary: ScanSummary;
|
||||||
|
violations: AccessibilityViolation[];
|
||||||
|
passes: AxeResult[];
|
||||||
|
incomplete: AxeResult[];
|
||||||
|
inapplicable: AxeResult[];
|
||||||
|
pageMetadata: PageMetadata;
|
||||||
|
wcagCompliance: WCAGCompliance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanSummary {
|
||||||
|
violationCount: number;
|
||||||
|
passCount: number;
|
||||||
|
incompleteCount: number;
|
||||||
|
inapplicableCount: number;
|
||||||
|
criticalIssues: number;
|
||||||
|
seriousIssues: number;
|
||||||
|
moderateIssues: number;
|
||||||
|
minorIssues: number;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageMetadata {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
viewport: ViewportSize;
|
||||||
|
language?: string;
|
||||||
|
doctype?: string;
|
||||||
|
hasLandmarks: boolean;
|
||||||
|
hasHeadings: boolean;
|
||||||
|
imagesCount: number;
|
||||||
|
formsCount: number;
|
||||||
|
linksCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WCAGCompliance {
|
||||||
|
level: WCAGLevel;
|
||||||
|
isCompliant: boolean;
|
||||||
|
criteriaResults: CriteriaResult[];
|
||||||
|
overallScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CriteriaResult {
|
||||||
|
criterion: string;
|
||||||
|
level: 'A' | 'AA' | 'AAA';
|
||||||
|
passed: boolean;
|
||||||
|
violations: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanJob {
|
||||||
|
id: string;
|
||||||
|
websiteId: string;
|
||||||
|
url: string;
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||||
|
options: AccessibilityScanOptions;
|
||||||
|
scheduledAt: Date;
|
||||||
|
startedAt?: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
error?: string;
|
||||||
|
resultId?: string;
|
||||||
|
retryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Website {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
userId: string;
|
||||||
|
scanSchedule?: ScanSchedule;
|
||||||
|
lastScanAt?: Date;
|
||||||
|
complianceScore?: number;
|
||||||
|
tags?: string[];
|
||||||
|
active: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanSchedule {
|
||||||
|
frequency: 'manual' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||||
|
dayOfWeek?: number;
|
||||||
|
dayOfMonth?: number;
|
||||||
|
hour?: number;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComplianceReport {
|
||||||
|
id: string;
|
||||||
|
websiteId: string;
|
||||||
|
period: ReportPeriod;
|
||||||
|
generatedAt: Date;
|
||||||
|
summary: ReportSummary;
|
||||||
|
trendsData: TrendData[];
|
||||||
|
detailedResults: AccessibilityScanResult[];
|
||||||
|
recommendations: string[];
|
||||||
|
format: 'pdf' | 'html' | 'json' | 'csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportPeriod {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSummary {
|
||||||
|
averageScore: number;
|
||||||
|
totalScans: number;
|
||||||
|
improvementRate: number;
|
||||||
|
criticalIssuesFixed: number;
|
||||||
|
newIssuesFound: number;
|
||||||
|
complianceLevel: WCAGLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendData {
|
||||||
|
date: Date;
|
||||||
|
score: number;
|
||||||
|
violationCount: number;
|
||||||
|
passCount: number;
|
||||||
|
}
|
||||||
155
apps/wcag-ada/shared/src/utils.ts
Normal file
155
apps/wcag-ada/shared/src/utils.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import type { ImpactValue } from 'axe-core';
|
||||||
|
import { IMPACT_SCORES, COMPLIANCE_THRESHOLDS, WCAG_TAGS } from './constants';
|
||||||
|
import type { AccessibilityScanResult, ScanSummary, WCAGLevel } from './types';
|
||||||
|
|
||||||
|
export function calculateComplianceScore(result: AccessibilityScanResult): number {
|
||||||
|
const { violations, passes } = result;
|
||||||
|
const totalChecks = violations.length + passes.length;
|
||||||
|
|
||||||
|
if (totalChecks === 0) return 100;
|
||||||
|
|
||||||
|
let weightedViolations = 0;
|
||||||
|
violations.forEach(violation => {
|
||||||
|
const impactScore = IMPACT_SCORES[violation.impact as keyof typeof IMPACT_SCORES] || 50;
|
||||||
|
weightedViolations += (impactScore / 100) * violation.nodes.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const score = Math.max(0, 100 - (weightedViolations / totalChecks) * 100);
|
||||||
|
return Math.round(score * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCompliantWithLevel(score: number, level: WCAGLevel['level']): boolean {
|
||||||
|
return score >= COMPLIANCE_THRESHOLDS[level] * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWCAGTags(level: WCAGLevel): string[] {
|
||||||
|
const key = `WCAG_${level.version.replace('.', '_')}_${level.level}` as keyof typeof WCAG_TAGS;
|
||||||
|
return [...(WCAG_TAGS[key] || [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeViolations(violations: AccessibilityScanResult['violations']): Partial<ScanSummary> {
|
||||||
|
const summary = {
|
||||||
|
criticalIssues: 0,
|
||||||
|
seriousIssues: 0,
|
||||||
|
moderateIssues: 0,
|
||||||
|
minorIssues: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
violations.forEach(violation => {
|
||||||
|
const count = violation.nodes.length;
|
||||||
|
switch (violation.impact) {
|
||||||
|
case 'critical':
|
||||||
|
summary.criticalIssues += count;
|
||||||
|
break;
|
||||||
|
case 'serious':
|
||||||
|
summary.seriousIssues += count;
|
||||||
|
break;
|
||||||
|
case 'moderate':
|
||||||
|
summary.moderateIssues += count;
|
||||||
|
break;
|
||||||
|
case 'minor':
|
||||||
|
summary.minorIssues += count;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateFixSuggestion(violationId: string): string {
|
||||||
|
const suggestions: Record<string, string> = {
|
||||||
|
'color-contrast': 'Increase the contrast ratio between text and background colors to meet WCAG standards.',
|
||||||
|
'image-alt': 'Add descriptive alt text to images. Use alt="" for decorative images.',
|
||||||
|
'label': 'Add labels to form controls using <label> elements or aria-label attributes.',
|
||||||
|
'button-name': 'Provide accessible names for buttons using text content, aria-label, or aria-labelledby.',
|
||||||
|
'link-name': 'Ensure links have descriptive text that explains their purpose.',
|
||||||
|
'heading-order': 'Use heading levels in sequential order without skipping levels.',
|
||||||
|
'landmark-one-main': 'Ensure the page has one main landmark using <main> or role="main".',
|
||||||
|
'page-has-heading-one': 'Add a single <h1> element to identify the main content of the page.',
|
||||||
|
'meta-viewport': 'Avoid disabling zoom by not using maximum-scale=1.0 in viewport meta tag.',
|
||||||
|
'duplicate-id': 'Ensure all id attributes are unique within the page.',
|
||||||
|
'aria-required-attr': 'Add all required ARIA attributes for the role being used.',
|
||||||
|
'aria-valid-attr': 'Use only valid ARIA attributes that are spelled correctly.',
|
||||||
|
'html-has-lang': 'Add a lang attribute to the <html> element to specify the page language.',
|
||||||
|
'document-title': 'Provide a descriptive <title> element in the document head.',
|
||||||
|
'list': 'Ensure <ul> and <ol> elements only contain <li>, <script>, or <template> elements.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return suggestions[violationId] || 'Review the element and ensure it meets accessibility standards.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatViolationTarget(target: string[]): string {
|
||||||
|
return target.join(' > ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateFixTime(impact: ImpactValue, nodeCount: number): number {
|
||||||
|
const baseTime: Record<ImpactValue, number> = {
|
||||||
|
critical: 30,
|
||||||
|
serious: 20,
|
||||||
|
moderate: 15,
|
||||||
|
minor: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (baseTime[impact] || 15) * nodeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupViolationsByWCAGCriteria(
|
||||||
|
violations: AccessibilityScanResult['violations']
|
||||||
|
): Record<string, AccessibilityScanResult['violations']> {
|
||||||
|
const grouped: Record<string, AccessibilityScanResult['violations']> = {};
|
||||||
|
|
||||||
|
violations.forEach(violation => {
|
||||||
|
violation.wcagCriteria?.forEach(criterion => {
|
||||||
|
if (!grouped[criterion]) {
|
||||||
|
grouped[criterion] = [];
|
||||||
|
}
|
||||||
|
grouped[criterion].push(violation);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateComplianceStatement(result: AccessibilityScanResult): string {
|
||||||
|
const { wcagCompliance, summary } = result;
|
||||||
|
const score = result.summary.score;
|
||||||
|
const level = wcagCompliance.level;
|
||||||
|
|
||||||
|
if (wcagCompliance.isCompliant) {
|
||||||
|
return `This page meets WCAG ${level.version} Level ${level.level} compliance standards with a score of ${score}%.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const majorIssues = summary.criticalIssues + summary.seriousIssues;
|
||||||
|
return `This page does not meet WCAG ${level.version} Level ${level.level} compliance. ` +
|
||||||
|
`Found ${majorIssues} major accessibility issues that need to be addressed. ` +
|
||||||
|
`Current compliance score: ${score}%.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPageMetadata(html: string): Partial<AccessibilityScanResult['pageMetadata']> {
|
||||||
|
const metadata: Partial<AccessibilityScanResult['pageMetadata']> = {};
|
||||||
|
|
||||||
|
// Extract title
|
||||||
|
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||||
|
if (titleMatch) {
|
||||||
|
metadata.title = titleMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract language
|
||||||
|
const langMatch = html.match(/<html[^>]*lang=["']([^"']+)["']/i);
|
||||||
|
if (langMatch) {
|
||||||
|
metadata.language = langMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count elements
|
||||||
|
metadata.imagesCount = (html.match(/<img[^>]*>/gi) || []).length;
|
||||||
|
metadata.formsCount = (html.match(/<form[^>]*>/gi) || []).length;
|
||||||
|
metadata.linksCount = (html.match(/<a[^>]*>/gi) || []).length;
|
||||||
|
|
||||||
|
// Check for landmarks
|
||||||
|
metadata.hasLandmarks = /<(main|nav|aside|header|footer)[^>]*>|role=["'](main|navigation|complementary|banner|contentinfo)["']/i.test(html);
|
||||||
|
|
||||||
|
// Check for headings
|
||||||
|
metadata.hasHeadings = /<h[1-6][^>]*>/i.test(html);
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
10
apps/wcag-ada/shared/tsconfig.json
Normal file
10
apps/wcag-ada/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
21
apps/wcag-ada/tsconfig.json
Normal file
21
apps/wcag-ada/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowJs": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"incremental": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", ".turbo"]
|
||||||
|
}
|
||||||
28
apps/wcag-ada/turbo.json
Normal file
28
apps/wcag-ada/turbo.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"globalDependencies": ["**/.env.*local"],
|
||||||
|
"pipeline": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**", ".next/**", "build/**"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"persistent": true,
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["build"],
|
||||||
|
"outputs": [],
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"typecheck": {
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"clean": {
|
||||||
|
"cache": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
apps/wcag-ada/worker/Dockerfile
Normal file
84
apps/wcag-ada/worker/Dockerfile
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Build stage
|
||||||
|
FROM oven/bun:1-alpine as builder
|
||||||
|
|
||||||
|
# Install dependencies for Prisma and Playwright
|
||||||
|
RUN apk add --no-cache openssl chromium
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy workspace files
|
||||||
|
COPY package.json bun.lockb ./
|
||||||
|
COPY apps/wcag-ada/worker/package.json ./apps/wcag-ada/worker/
|
||||||
|
COPY apps/wcag-ada/scanner/package.json ./apps/wcag-ada/scanner/
|
||||||
|
COPY apps/wcag-ada/config/package.json ./apps/wcag-ada/config/
|
||||||
|
COPY apps/wcag-ada/shared/package.json ./apps/wcag-ada/shared/
|
||||||
|
COPY lib/service/core-config/package.json ./lib/service/core-config/
|
||||||
|
COPY lib/service/core-logger/package.json ./lib/service/core-logger/
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY apps/wcag-ada/worker ./apps/wcag-ada/worker
|
||||||
|
COPY apps/wcag-ada/scanner ./apps/wcag-ada/scanner
|
||||||
|
COPY apps/wcag-ada/config ./apps/wcag-ada/config
|
||||||
|
COPY apps/wcag-ada/shared ./apps/wcag-ada/shared
|
||||||
|
COPY lib/service/core-config ./lib/service/core-config
|
||||||
|
COPY lib/service/core-logger ./lib/service/core-logger
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
WORKDIR /app/apps/wcag-ada/worker
|
||||||
|
RUN bunx prisma generate
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM oven/bun:1-alpine
|
||||||
|
|
||||||
|
# Install runtime dependencies including Chromium
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
openssl \
|
||||||
|
chromium \
|
||||||
|
nss \
|
||||||
|
freetype \
|
||||||
|
freetype-dev \
|
||||||
|
harfbuzz \
|
||||||
|
ca-certificates \
|
||||||
|
ttf-freefont \
|
||||||
|
nodejs \
|
||||||
|
yarn
|
||||||
|
|
||||||
|
# Tell Puppeteer/Playwright to use installed Chromium
|
||||||
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||||
|
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser \
|
||||||
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true \
|
||||||
|
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/worker/dist ./dist
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/worker/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/worker/prisma ./prisma
|
||||||
|
COPY --from=builder /app/apps/wcag-ada/worker/package.json ./
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
RUN chown -R nodejs:nodejs /app
|
||||||
|
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
# Expose port for health check
|
||||||
|
EXPOSE 3002
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/health || exit 1
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["bun", "run", "start"]
|
||||||
29
apps/wcag-ada/worker/package.json
Normal file
29
apps/wcag-ada/worker/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "@wcag-ada/worker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -b",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@wcag-ada/shared": "workspace:*",
|
||||||
|
"@wcag-ada/scanner": "workspace:*",
|
||||||
|
"@wcag-ada/config": "workspace:*",
|
||||||
|
"bullmq": "^5.1.1",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"@prisma/client": "^5.8.0",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"pino": "^8.17.2",
|
||||||
|
"pino-pretty": "^10.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.5",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue