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