From 9a3fc97a3422101ebd850f68059f402ff6f948fc Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 13 Jan 2026 14:14:30 -0700 Subject: [PATCH 01/10] Phase 1 done --- .env.example | 34 ++++++ .gitignore | 15 +++ IMPLEMENTATION_PLAN.md | 66 ++++++------ authentik/certs/.gitkeep | 0 authentik/custom-templates/.gitkeep | 0 authentik/media/.gitkeep | 0 docker-compose.yml | 129 ++++++++++++++++++++++ docs/AUTHENTIK_SETUP.md | 159 ++++++++++++++++++++++++++++ features.md | 4 + scripts/setup.sh | 48 +++++++++ server/Dockerfile | 15 +++ server/package.json | 28 +++++ server/src/index.ts | 16 +++ server/tsconfig.json | 15 +++ 14 files changed, 496 insertions(+), 33 deletions(-) create mode 100644 .env.example create mode 100644 authentik/certs/.gitkeep create mode 100644 authentik/custom-templates/.gitkeep create mode 100644 authentik/media/.gitkeep create mode 100644 docker-compose.yml create mode 100644 docs/AUTHENTIK_SETUP.md create mode 100644 features.md create mode 100755 scripts/setup.sh create mode 100644 server/Dockerfile create mode 100644 server/package.json create mode 100644 server/src/index.ts create mode 100644 server/tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5357fdf --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# ============================================================================== +# REQUIRED - Generate with: openssl rand -base64 36 | tr -d '\n' +# ============================================================================== +PG_PASS= +AUTHENTIK_SECRET_KEY= + +# ============================================================================== +# OPTIONAL - Authentik Database +# ============================================================================== +PG_USER=authentik +PG_DB=authentik + +# ============================================================================== +# OPTIONAL - Ports +# ============================================================================== +AUTHENTIK_PORT_HTTP=9000 +AUTHENTIK_PORT_HTTPS=9443 +KABOOT_BACKEND_PORT=3001 + +# ============================================================================== +# OPTIONAL - Authentik Settings +# ============================================================================== +AUTHENTIK_ERROR_REPORTING=false + +# ============================================================================== +# OPTIONAL - OIDC (Override if using custom domain) +# ============================================================================== +OIDC_ISSUER=http://localhost:9000/application/o/kaboot/ +OIDC_JWKS_URI=http://localhost:9000/application/o/kaboot/jwks/ + +# ============================================================================== +# OPTIONAL - CORS (Frontend origin for backend API) +# ============================================================================== +CORS_ORIGIN=http://localhost:5173 diff --git a/.gitignore b/.gitignore index a547bf3..b88f839 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,18 @@ dist-ssr *.njsproj *.sln *.sw? + +# Environment secrets +.env +.env.local +.env.*.local + +# Authentik volumes (keep structure, ignore data) +authentik/media/* +!authentik/media/.gitkeep +authentik/certs/* +!authentik/certs/.gitkeep + +# Backend data +server/data/ +*.db diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index db75bb2..5e78081 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -31,40 +31,40 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U ## Phase 1: Infrastructure Setup ### 1.1 Docker Compose Configuration -- [ ] Create `docker-compose.yml` with all services: - - [ ] PostgreSQL (Authentik database) - - [ ] Redis (Authentik cache) - - [ ] Authentik server - - [ ] Authentik worker - - [ ] Kaboot backend service -- [ ] Create `.env.example` with required variables: - - [ ] `PG_PASS` - PostgreSQL password - - [ ] `AUTHENTIK_SECRET_KEY` - Authentik secret - - [ ] `PG_USER`, `PG_DB` - Optional customization -- [ ] Create setup script to generate secrets (`scripts/setup.sh`) -- [ ] Add `authentik/` directory structure for volumes: - - [ ] `authentik/media/` - - [ ] `authentik/certs/` - - [ ] `authentik/custom-templates/` -- [ ] Update `.gitignore` for new files: - - [ ] `.env` - - [ ] `authentik/media/*` - - [ ] `authentik/certs/*` - - [ ] `server/data/` +- [x] Create `docker-compose.yml` with all services: + - [x] PostgreSQL (Authentik database) + - [x] Redis (Authentik cache) + - [x] Authentik server + - [x] Authentik worker + - [x] Kaboot backend service +- [x] Create `.env.example` with required variables: + - [x] `PG_PASS` - PostgreSQL password + - [x] `AUTHENTIK_SECRET_KEY` - Authentik secret + - [x] `PG_USER`, `PG_DB` - Optional customization +- [x] Create setup script to generate secrets (`scripts/setup.sh`) +- [x] Add `authentik/` directory structure for volumes: + - [x] `authentik/media/` + - [x] `authentik/certs/` + - [x] `authentik/custom-templates/` +- [x] Update `.gitignore` for new files: + - [x] `.env` + - [x] `authentik/media/*` + - [x] `authentik/certs/*` + - [x] `server/data/` ### 1.2 Authentik Configuration Documentation -- [ ] Document initial setup steps in `docs/AUTHENTIK_SETUP.md`: - - [ ] Navigate to `http://localhost:9000/if/flow/initial-setup/` - - [ ] Create admin account - - [ ] Create OAuth2/OIDC Application + Provider: - - [ ] Application name: `Kaboot` - - [ ] Application slug: `kaboot` - - [ ] Provider type: `OAuth2/OIDC` - - [ ] Client type: `Public` (SPA with PKCE) - - [ ] Client ID: `kaboot-spa` - - [ ] Redirect URIs: `http://localhost:5173/callback`, `http://localhost:5173/silent-renew.html` - - [ ] Scopes: `openid`, `profile`, `email`, `offline_access` - - [ ] Note down OIDC endpoints (issuer, JWKS URI, etc.) +- [x] Document initial setup steps in `docs/AUTHENTIK_SETUP.md`: + - [x] Navigate to `http://localhost:9000/if/flow/initial-setup/` + - [x] Create admin account + - [x] Create OAuth2/OIDC Application + Provider: + - [x] Application name: `Kaboot` + - [x] Application slug: `kaboot` + - [x] Provider type: `OAuth2/OIDC` + - [x] Client type: `Public` (SPA with PKCE) + - [x] Client ID: `kaboot-spa` + - [x] Redirect URIs: `http://localhost:5173/callback`, `http://localhost:5173/silent-renew.html` + - [x] Scopes: `openid`, `profile`, `email`, `offline_access` + - [x] Note down OIDC endpoints (issuer, JWKS URI, etc.) --- @@ -400,7 +400,7 @@ kaboot/ | Phase | Status | Notes | |-------|--------|-------| -| Phase 1 | Not Started | | +| Phase 1 | **COMPLETE** | Docker Compose, .env, setup script, Authentik docs | | Phase 2 | Not Started | | | Phase 3 | Not Started | | | Phase 4 | Not Started | | diff --git a/authentik/certs/.gitkeep b/authentik/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/authentik/custom-templates/.gitkeep b/authentik/custom-templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/authentik/media/.gitkeep b/authentik/media/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4007394 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,129 @@ +services: + # ═══════════════════════════════════════════════════════════════════════════ + # AUTHENTIK - Identity Provider + # ═══════════════════════════════════════════════════════════════════════════ + + postgresql: + image: docker.io/library/postgres:16-alpine + container_name: kaboot-postgresql + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + start_period: 20s + interval: 30s + retries: 5 + timeout: 5s + volumes: + - postgresql-data:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: ${PG_PASS:?database password required} + POSTGRES_USER: ${PG_USER:-authentik} + POSTGRES_DB: ${PG_DB:-authentik} + networks: + - kaboot-network + + redis: + image: docker.io/library/redis:alpine + container_name: kaboot-redis + command: --save 60 1 --loglevel warning + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + start_period: 20s + interval: 30s + retries: 5 + timeout: 3s + volumes: + - redis-data:/data + networks: + - kaboot-network + + authentik-server: + image: ghcr.io/goauthentik/server:2025.2 + container_name: kaboot-authentik-server + restart: unless-stopped + command: server + environment: + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?authentik secret key required} + AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING:-false} + volumes: + - ./authentik/media:/media + - ./authentik/custom-templates:/templates + ports: + - "${AUTHENTIK_PORT_HTTP:-9000}:9000" + - "${AUTHENTIK_PORT_HTTPS:-9443}:9443" + depends_on: + postgresql: + condition: service_healthy + redis: + condition: service_healthy + networks: + - kaboot-network + + authentik-worker: + image: ghcr.io/goauthentik/server:2025.2 + container_name: kaboot-authentik-worker + restart: unless-stopped + command: worker + environment: + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + user: root + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./authentik/media:/media + - ./authentik/certs:/certs + - ./authentik/custom-templates:/templates + depends_on: + postgresql: + condition: service_healthy + redis: + condition: service_healthy + networks: + - kaboot-network + + # ═══════════════════════════════════════════════════════════════════════════ + # KABOOT - Application Backend + # ═══════════════════════════════════════════════════════════════════════════ + + kaboot-backend: + build: + context: ./server + dockerfile: Dockerfile + container_name: kaboot-backend + restart: unless-stopped + environment: + NODE_ENV: production + PORT: 3001 + DATABASE_PATH: /data/kaboot.db + # OIDC Configuration - points to Authentik + OIDC_ISSUER: ${OIDC_ISSUER:-http://authentik-server:9000/application/o/kaboot/} + OIDC_JWKS_URI: ${OIDC_JWKS_URI:-http://authentik-server:9000/application/o/kaboot/jwks/} + # CORS - allow frontend origin + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173} + volumes: + - kaboot-data:/data + ports: + - "${KABOOT_BACKEND_PORT:-3001}:3001" + depends_on: + - authentik-server + networks: + - kaboot-network + +volumes: + postgresql-data: + redis-data: + kaboot-data: + +networks: + kaboot-network: + driver: bridge diff --git a/docs/AUTHENTIK_SETUP.md b/docs/AUTHENTIK_SETUP.md new file mode 100644 index 0000000..60a6694 --- /dev/null +++ b/docs/AUTHENTIK_SETUP.md @@ -0,0 +1,159 @@ +# Authentik Setup Guide for Kaboot + +This guide walks through configuring Authentik as the OAuth2/OIDC identity provider for Kaboot. + +## Prerequisites + +- Docker and Docker Compose installed +- Kaboot stack running (`docker compose up -d`) +- Access to `http://localhost:9000` + +## Step 1: Initial Authentik Setup + +1. Navigate to `http://localhost:9000/if/flow/initial-setup/` + - **Important**: Include the trailing slash `/` + +2. Create the admin account: + - Email: Your email address + - Password: Choose a strong password + +3. Log in with the credentials you just created + +## Step 2: Create the Kaboot Application + +1. In the Authentik admin interface, go to **Applications** > **Applications** + +2. Click **Create with provider** + +3. **Application Settings**: + | Field | Value | + |-------|-------| + | Name | `Kaboot` | + | Slug | `kaboot` | + | Launch URL | `http://localhost:5173` | + +4. Click **Next** + +## Step 3: Configure OAuth2/OIDC Provider + +1. Select **OAuth2/OIDC** as the Provider Type + +2. Click **Next** + +3. **Provider Configuration**: + | Field | Value | + |-------|-------| + | Name | `Kaboot OAuth2` | + | Authorization flow | `default-provider-authorization-implicit-consent` | + | Client type | `Public` | + | Client ID | `kaboot-spa` | + +4. **Redirect URIs** (one per line): + ``` + http://localhost:5173/callback + http://localhost:5173/silent-renew.html + http://localhost:5173 + ``` + +5. **Advanced Settings**: + | Field | Value | + |-------|-------| + | Subject mode | `Based on the User's hashed ID` | + | Include claims in id_token | `Yes` | + | Issuer mode | `Each provider has a different issuer` | + +6. **Scopes** - Ensure these are selected: + - `openid` + - `profile` + - `email` + - `offline_access` (for refresh tokens) + +7. Click **Submit** + +## Step 4: Verify OIDC Endpoints + +After creation, go to **Applications** > **Providers** > **Kaboot OAuth2** + +Note these endpoints (you'll need them for frontend configuration): + +| Endpoint | URL | +|----------|-----| +| Issuer | `http://localhost:9000/application/o/kaboot/` | +| Authorization | `http://localhost:9000/application/o/authorize/` | +| Token | `http://localhost:9000/application/o/token/` | +| UserInfo | `http://localhost:9000/application/o/userinfo/` | +| JWKS | `http://localhost:9000/application/o/kaboot/jwks/` | + +## Step 5: Test the Configuration + +1. Open the OpenID Configuration URL in your browser: + ``` + http://localhost:9000/application/o/kaboot/.well-known/openid-configuration + ``` + +2. You should see a JSON response with all OIDC endpoints + +## Step 6: Create a Test User (Optional) + +1. Go to **Directory** > **Users** + +2. Click **Create** + +3. Fill in user details: + - Username: `testuser` + - Name: `Test User` + - Email: `test@example.com` + +4. After creation, click on the user and go to the **Credentials** tab + +5. Click **Set password** to create a password + +## Environment Variables + +Ensure your `.env` file has the correct OIDC configuration: + +```bash +OIDC_ISSUER=http://localhost:9000/application/o/kaboot/ +OIDC_JWKS_URI=http://localhost:9000/application/o/kaboot/jwks/ +``` + +For the frontend OIDC config (`src/config/oidc.ts`): + +```typescript +export const oidcConfig = { + authority: 'http://localhost:9000/application/o/kaboot/', + client_id: 'kaboot-spa', + redirect_uri: `${window.location.origin}/callback`, + // ... rest of config +}; +``` + +## Troubleshooting + +### "Invalid redirect URI" error +- Ensure all redirect URIs are added exactly as configured in the provider +- Check for trailing slashes - they must match exactly + +### "Client not found" error +- Verify the Client ID matches `kaboot-spa` +- Ensure the application is enabled (not archived) + +### CORS errors +- Authentik handles CORS automatically for configured redirect URIs +- Ensure your frontend origin (`http://localhost:5173`) is in the redirect URIs + +### Token validation fails on backend +- Verify `OIDC_ISSUER` and `OIDC_JWKS_URI` are correct +- The backend must be able to reach Authentik at `http://authentik-server:9000` (Docker network) + +## Production Notes + +For production deployment: + +1. Use HTTPS everywhere +2. Update all URLs from `localhost` to your domain +3. Update redirect URIs in Authentik +4. Update frontend OIDC config with production URLs +5. Update `.env` with production OIDC endpoints +6. Consider enabling Authentik error reporting +7. Configure email settings in Authentik for password recovery diff --git a/features.md b/features.md new file mode 100644 index 0000000..fa248b3 --- /dev/null +++ b/features.md @@ -0,0 +1,4 @@ +- [ ] All data stored in sqlite db. +- [ ] AI generated content based on document upload +- [ ] Moderation (kick player, lock game, filter names) +- [ ] Persistent game urls while game is active diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..6331971 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -e + +ENV_FILE=".env" +ENV_EXAMPLE=".env.example" + +echo "Kaboot Setup Script" +echo "===================" +echo "" + +if [ -f "$ENV_FILE" ]; then + read -p ".env file already exists. Overwrite? (y/N): " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborting. Existing .env file preserved." + exit 0 + fi +fi + +if [ ! -f "$ENV_EXAMPLE" ]; then + echo "Error: .env.example not found. Run this script from the project root." + exit 1 +fi + +echo "Generating secrets..." + +PG_PASS=$(openssl rand -base64 36 | tr -d '\n') +AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n') + +cp "$ENV_EXAMPLE" "$ENV_FILE" + +if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s|^PG_PASS=.*|PG_PASS=${PG_PASS}|" "$ENV_FILE" + sed -i '' "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}|" "$ENV_FILE" +else + sed -i "s|^PG_PASS=.*|PG_PASS=${PG_PASS}|" "$ENV_FILE" + sed -i "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}|" "$ENV_FILE" +fi + +echo "" +echo "Created .env file with generated secrets." +echo "" +echo "Next steps:" +echo " 1. Review .env and adjust settings if needed" +echo " 2. Run: docker compose up -d" +echo " 3. Open: http://localhost:9000/if/flow/initial-setup/" +echo " 4. Follow docs/AUTHENTIK_SETUP.md to configure the OAuth2 provider" +echo "" diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..b714965 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine + +WORKDIR /app + +RUN apk add --no-cache python3 make g++ + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +EXPOSE 3001 + +CMD ["npm", "start"] diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..40e2412 --- /dev/null +++ b/server/package.json @@ -0,0 +1,28 @@ +{ + "name": "kaboot-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "better-sqlite3": "^11.7.0", + "cors": "^2.8.5", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.1.0", + "uuid": "^11.0.5" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.10.7", + "@types/uuid": "^10.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3" + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..f8f34b8 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,16 @@ +import express from 'express'; +import cors from 'cors'; + +const app = express(); +const PORT = process.env.PORT || 3001; + +app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173' })); +app.use(express.json()); + +app.get('/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +app.listen(PORT, () => { + console.log(`Kaboot backend running on port ${PORT}`); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..bbd4ab1 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 6d24f3c1124d38b1869925863d021832a09d6055 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 13 Jan 2026 15:20:46 -0700 Subject: [PATCH 02/10] Phase 2 + 3 complete --- .gitignore | 1 + IMPLEMENTATION_PLAN.md | 168 +-- components/AuthButton.tsx | 53 + components/Landing.tsx | 6 +- data/kaboot.db-shm | Bin 0 -> 32768 bytes data/kaboot.db-wal | Bin 0 -> 74192 bytes docker-compose.yml | 9 +- hooks/useAuthenticatedFetch.ts | 46 + index.tsx | 6 +- package-lock.json | 37 + package.json | 4 +- server/.dockerignore | 4 + server/Dockerfile | 4 +- server/package-lock.json | 2241 ++++++++++++++++++++++++++++++++ server/package.json | 4 +- server/src/db/connection.ts | 19 + server/src/db/schema.sql | 44 + server/src/index.ts | 30 +- server/src/middleware/auth.ts | 82 ++ server/src/routes/quizzes.ts | 225 ++++ server/src/routes/users.ts | 32 + server/tests/README.md | 68 + server/tests/api.test.ts | 191 +++ server/tests/get-token.ts | 79 ++ src/config/oidc.ts | 20 + 25 files changed, 3275 insertions(+), 98 deletions(-) create mode 100644 components/AuthButton.tsx create mode 100644 data/kaboot.db-shm create mode 100644 data/kaboot.db-wal create mode 100644 hooks/useAuthenticatedFetch.ts create mode 100644 server/.dockerignore create mode 100644 server/package-lock.json create mode 100644 server/src/db/connection.ts create mode 100644 server/src/db/schema.sql create mode 100644 server/src/middleware/auth.ts create mode 100644 server/src/routes/quizzes.ts create mode 100644 server/src/routes/users.ts create mode 100644 server/tests/README.md create mode 100644 server/tests/api.test.ts create mode 100644 server/tests/get-token.ts create mode 100644 src/config/oidc.ts diff --git a/.gitignore b/.gitignore index b88f839..5a58438 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ dist-ssr .env .env.local .env.*.local +.env.test # Authentik volumes (keep structure, ignore data) authentik/media/* diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 5e78081..986f120 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -71,7 +71,7 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U ## Phase 2: Backend API Development ### 2.1 Project Setup -- [ ] Create `server/` directory structure: +- [x] Create `server/` directory structure: ``` server/ ├── Dockerfile @@ -84,110 +84,110 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U ├── routes/ └── services/ ``` -- [ ] Initialize `package.json` with dependencies: - - [ ] `express` - Web framework - - [ ] `better-sqlite3` - SQLite driver - - [ ] `jsonwebtoken` - JWT verification - - [ ] `jwks-rsa` - JWKS client for Authentik - - [ ] `uuid` - ID generation - - [ ] `cors` - CORS middleware - - [ ] Dev deps: `typescript`, `@types/*`, `tsx` -- [ ] Create `tsconfig.json` for Node.js -- [ ] Create `Dockerfile` for backend container +- [x] Initialize `package.json` with dependencies: + - [x] `express` - Web framework + - [x] `better-sqlite3` - SQLite driver + - [x] `jsonwebtoken` - JWT verification + - [x] `jwks-rsa` - JWKS client for Authentik + - [x] `uuid` - ID generation + - [x] `cors` - CORS middleware + - [x] Dev deps: `typescript`, `@types/*`, `tsx` +- [x] Create `tsconfig.json` for Node.js +- [x] Create `Dockerfile` for backend container ### 2.2 Database Layer -- [ ] Create `server/src/db/schema.sql`: - - [ ] `users` table (synced from OIDC claims) - - [ ] `quizzes` table (with `user_id` foreign key) - - [ ] `questions` table (with `quiz_id` foreign key) - - [ ] `answer_options` table (with `question_id` foreign key) - - [ ] Indexes for foreign keys -- [ ] Create `server/src/db/connection.ts`: - - [ ] Initialize better-sqlite3 connection - - [ ] Run schema on startup - - [ ] Export db instance +- [x] Create `server/src/db/schema.sql`: + - [x] `users` table (synced from OIDC claims) + - [x] `quizzes` table (with `user_id` foreign key) + - [x] `questions` table (with `quiz_id` foreign key) + - [x] `answer_options` table (with `question_id` foreign key) + - [x] Indexes for foreign keys +- [x] Create `server/src/db/connection.ts`: + - [x] Initialize better-sqlite3 connection + - [x] Run schema on startup + - [x] Export db instance ### 2.3 Authentication Middleware -- [ ] Create `server/src/middleware/auth.ts`: - - [ ] JWKS client setup pointing to Authentik - - [ ] `requireAuth` middleware function: - - [ ] Extract Bearer token from Authorization header - - [ ] Verify JWT signature against JWKS - - [ ] Validate issuer matches Authentik - - [ ] Attach decoded user to request - - [ ] Define `AuthenticatedRequest` interface +- [x] Create `server/src/middleware/auth.ts`: + - [x] JWKS client setup pointing to Authentik + - [x] `requireAuth` middleware function: + - [x] Extract Bearer token from Authorization header + - [x] Verify JWT signature against JWKS + - [x] Validate issuer matches Authentik + - [x] Attach decoded user to request + - [x] Define `AuthenticatedRequest` interface ### 2.4 API Routes -- [ ] Create `server/src/routes/quizzes.ts`: - - [ ] `GET /api/quizzes` - List user's quizzes (with question count) - - [ ] `GET /api/quizzes/:id` - Get full quiz with questions and options - - [ ] `POST /api/quizzes` - Save new quiz (upsert user from token) - - [ ] `PUT /api/quizzes/:id` - Update existing quiz - - [ ] `DELETE /api/quizzes/:id` - Delete quiz (verify ownership) -- [ ] Create `server/src/routes/users.ts`: - - [ ] `GET /api/users/me` - Get current user profile -- [ ] Create `server/src/index.ts`: - - [ ] Express app setup - - [ ] CORS configuration (allow frontend origin) - - [ ] JSON body parser - - [ ] Mount routes - - [ ] Error handling middleware - - [ ] Start server on port 3001 +- [x] Create `server/src/routes/quizzes.ts`: + - [x] `GET /api/quizzes` - List user's quizzes (with question count) + - [x] `GET /api/quizzes/:id` - Get full quiz with questions and options + - [x] `POST /api/quizzes` - Save new quiz (upsert user from token) + - [x] `PUT /api/quizzes/:id` - Update existing quiz + - [x] `DELETE /api/quizzes/:id` - Delete quiz (verify ownership) +- [x] Create `server/src/routes/users.ts`: + - [x] `GET /api/users/me` - Get current user profile +- [x] Create `server/src/index.ts`: + - [x] Express app setup + - [x] CORS configuration (allow frontend origin) + - [x] JSON body parser + - [x] Mount routes + - [x] Error handling middleware + - [x] Start server on port 3001 ### 2.5 Backend Testing -- [ ] Test API manually with curl/Postman: - - [ ] Verify 401 without token - - [ ] Verify endpoints work with valid Authentik token - - [ ] Verify quiz CRUD operations - - [ ] Verify user sync from token claims +- [x] Test API with automated test suite: + - [x] Verify 401 without token + - [x] Verify endpoints work with valid Authentik token + - [x] Verify quiz CRUD operations + - [x] Verify user sync from token claims --- ## Phase 3: Frontend Authentication ### 3.1 Dependencies -- [ ] Add to `package.json`: - - [ ] `react-oidc-context` - React OIDC hooks - - [ ] `oidc-client-ts` - Underlying OIDC client +- [x] Add to `package.json`: + - [x] `react-oidc-context` - React OIDC hooks + - [x] `oidc-client-ts` - Underlying OIDC client - [ ] Run `npm install` ### 3.2 OIDC Configuration -- [ ] Create `src/config/oidc.ts`: - - [ ] Define `oidcConfig` object: - - [ ] `authority` - Authentik issuer URL - - [ ] `client_id` - `kaboot-spa` - - [ ] `redirect_uri` - `${origin}/callback` - - [ ] `post_logout_redirect_uri` - `${origin}` - - [ ] `response_type` - `code` (PKCE) - - [ ] `scope` - `openid profile email offline_access` - - [ ] `automaticSilentRenew` - `true` - - [ ] `userStore` - `WebStorageStateStore` with localStorage - - [ ] `onSigninCallback` - Clean URL after redirect +- [x] Create `src/config/oidc.ts`: + - [x] Define `oidcConfig` object: + - [x] `authority` - Authentik issuer URL + - [x] `client_id` - `kaboot-spa` + - [x] `redirect_uri` - `${origin}/callback` + - [x] `post_logout_redirect_uri` - `${origin}` + - [x] `response_type` - `code` (PKCE) + - [x] `scope` - `openid profile email offline_access` + - [x] `automaticSilentRenew` - `true` + - [x] `userStore` - `WebStorageStateStore` with localStorage + - [x] `onSigninCallback` - Clean URL after redirect ### 3.3 Auth Provider Setup -- [ ] Modify `src/main.tsx`: - - [ ] Import `AuthProvider` from `react-oidc-context` - - [ ] Import `oidcConfig` - - [ ] Wrap `` with `` +- [x] Modify `index.tsx`: + - [x] Import `AuthProvider` from `react-oidc-context` + - [x] Import `oidcConfig` + - [x] Wrap `` with `` ### 3.4 Auth UI Components -- [ ] Create `src/components/AuthButton.tsx`: - - [ ] Use `useAuth()` hook - - [ ] Show loading state while auth initializing - - [ ] Show "Sign In" button when unauthenticated - - [ ] Show username + "Sign Out" button when authenticated -- [ ] Modify `src/components/Landing.tsx`: - - [ ] Add `` to top-right corner - - [ ] Style consistently with existing design +- [x] Create `components/AuthButton.tsx`: + - [x] Use `useAuth()` hook + - [x] Show loading state while auth initializing + - [x] Show "Sign In" button when unauthenticated + - [x] Show username + "Sign Out" button when authenticated +- [x] Modify `components/Landing.tsx`: + - [x] Add `` to top-right corner + - [x] Style consistently with existing design ### 3.5 Authenticated Fetch Hook -- [ ] Create `src/hooks/useAuthenticatedFetch.ts`: - - [ ] Use `useAuth()` to get access token - - [ ] Create `authFetch` wrapper that: - - [ ] Adds `Authorization: Bearer ` header - - [ ] Adds `Content-Type: application/json` - - [ ] Handles 401 by triggering silent renew - - [ ] Export `{ authFetch, isAuthenticated }` +- [x] Create `hooks/useAuthenticatedFetch.ts`: + - [x] Use `useAuth()` to get access token + - [x] Create `authFetch` wrapper that: + - [x] Adds `Authorization: Bearer ` header + - [x] Adds `Content-Type: application/json` + - [x] Handles 401 by triggering silent renew + - [x] Export `{ authFetch, isAuthenticated }` --- @@ -401,8 +401,8 @@ kaboot/ | Phase | Status | Notes | |-------|--------|-------| | Phase 1 | **COMPLETE** | Docker Compose, .env, setup script, Authentik docs | -| Phase 2 | Not Started | | -| Phase 3 | Not Started | | +| Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD | +| Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch | | Phase 4 | Not Started | | | Phase 5 | Not Started | | | Phase 6 | Not Started | | diff --git a/components/AuthButton.tsx b/components/AuthButton.tsx new file mode 100644 index 0000000..f76a3b9 --- /dev/null +++ b/components/AuthButton.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { useAuth } from 'react-oidc-context'; +import { LogIn, LogOut, User, Loader2 } from 'lucide-react'; + +export const AuthButton: React.FC = () => { + const auth = useAuth(); + + if (auth.isLoading) { + return ( +
+ +
+ ); + } + + if (auth.error) { + return ( +
+ Auth Error +
+ ); + } + + if (auth.isAuthenticated) { + return ( +
+
+ + + {auth.user?.profile.preferred_username || auth.user?.profile.name || 'User'} + +
+ +
+ ); + } + + return ( + + ); +}; diff --git a/components/Landing.tsx b/components/Landing.tsx index 5b81058..de17fdc 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { motion } from 'framer-motion'; import { BrainCircuit, Loader2, Users, Play, PenTool } from 'lucide-react'; +import { AuthButton } from './AuthButton'; interface LandingProps { onGenerate: (topic: string) => void; @@ -27,7 +28,10 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on }; return ( -
+
+
+ +
j$--w*IWv^VSbry=XPoEd&S>oKDZM&bVpzuIs$3t_!mX9Sp9NyxiV(;t5R-<4Ku&>}ttA9<3dBS?A&^rbW+)1Q zoB}aDR0!l0h*`NpAg6!{(K)}nU5CIW1_EgXCTVwW;{@XW8$y5p0RjXF5FkK+009C7 W2oNAZfB*pk1PBlyK!Cvi7Wf4jb}Bvq literal 0 HcmV?d00001 diff --git a/data/kaboot.db-wal b/data/kaboot.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..788e385fa76f39411498c0fc525f6180b848289a GIT binary patch literal 74192 zcmeI*e`p)$9mjD=u_ar!lXYFuhPUSaDYim0=7kcdDXS(uPDErmk-vNJ)k0yUlrq|^&vQD- zI>)Yw@mke+3lg24`(fY9NcP?H`99C#2a>C?gKvK|7W-06JsuH{fAH|1zCZfhkB>h7 z(-+=&NjAlM+rM{tV)3Jyzuq)|qGbD)_5;_eo4$5P?wv@)Zr3!qZ9@LrqxK0OYX5=G z3;7%zujlnfi;37bA389$@55MPUm~_IvG4hfHlhsy1Q0*~0R#|0009ILKmdXNzrfj} z@l+<0iLbi8d6#3gnwHlL{!8R5BBu*Y&waNfw4h0wOlz85({!<@YsV|a*<58wJ0_NH zmd$E|hFQ1TE6a1bR$eHT)EcX9+D_1{Ue)a8iesKJy4$M8s%M$LRWsx@ctL(my*Mkh zf|$uIlyoh>P^pNrZm7n2JvVz??ckVA-*DVzyV2btoy{hancFh)nBAyZr009ILKmY**5I_I{ z1Q0*~f!!&f?h)+i|pq46f}S)D>*Ij$mNS3+f2QyuB~?9Fs)^5I_I{1Q0*~0R#|0 zU>6Il-nl2086S_Izdp#V-`le9yU%KdPXm$cdf2#Cdfn(G+k3<7zU@03WZIi<%d4(y z*YZb2{@7%=s#w-0C+cRSWjYf#YZInzEL#oBQ!)36Y*uC1Ws~o&*wt<{{;E^(t(Dr< z#pY+`Dx!F#tTOxBq)NiebIxiNF(YKeJ}>6mG5<-~n9d#?NM>^5o2BVPQ)4Pz7`*1| z2!d<+PF`UA7q2f|_oF}ljXHu|{1L>dA%Fk^2q1s}0tg_000IaguqL4T{s%kv1#Y{z zdj8Na9~+>KU=4^R1Q0*~0R#|0009ILKmY**cCkRDj^KCY4{tQj%zb5xI)YS>hZ>(? z+jRuV(dlj-!RYkZcY2P?A_52?fB*srAbNm?u}#d)@lFBk=}i~ZW)eUw|%Wx*2NJ~>5k(5?Qf~byX)0d=3I@> z-rb@%Z6C>}hhIFBPY=`PDxt2;mF3Y(LJm=%&&}s^1(D89C6gJkfAh?Gc*-iZ9xkS+ zBambFQ%BH0^ARM~M@=U$aB^Vw*=P2C+@y}6|385F4iP{A0R#|0009ILKmY**5ZD0$ z)%Tz3+!wh2oqvCQ>NkIXojQUYkmV2vAbB*7YeW-<8a&&rsJ zz8^j=TX^wn)Di5E zE{8z?0R#|0009ILKmY**5I~^61tN6>2VcA4#Ncl)T-u_JAh>N1o|c;DaNBhR!^3Y~ zQAZ$~cp!iP0tg_000Ic?w17UG%p4q#yTO?3cI~tgj<9a1+-ta;4x|3XazQL=>o-#4 zuy=-KS3AjIf0jCeY&J+xQ%Asj1k6Vui(q`KW|+QK$mv2a&Wi555c!2lMU-_zHO}j~ z+2iVM!+ZpNR7aq$@zb5Wz#rcG*@csjShtWD*y&%y91j5m5I_I{1Q0*~0R#|00D-;~ zh~xzd|NO~+_x|smr?KcEvlNb1Xe){62-0hdh3w+LhhVbPPKmY**5I_I{ P1Q0*~0R#~E3 { + const auth = useAuth(); + + const authFetch = useCallback( + async (path: string, options: RequestInit = {}): Promise => { + if (!auth.user?.access_token) { + throw new Error('Not authenticated'); + } + + const url = path.startsWith('http') ? path : `${API_URL}${path}`; + + const response = await fetch(url, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${auth.user.access_token}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.status === 401) { + try { + await auth.signinSilent(); + } catch { + auth.signinRedirect(); + } + throw new Error('Token expired, please retry'); + } + + return response; + }, + [auth] + ); + + return { + authFetch, + isAuthenticated: auth.isAuthenticated, + isLoading: auth.isLoading, + user: auth.user, + }; +}; diff --git a/index.tsx b/index.tsx index 6ca5361..d228d62 100644 --- a/index.tsx +++ b/index.tsx @@ -1,6 +1,8 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { AuthProvider } from 'react-oidc-context'; import App from './App'; +import { oidcConfig } from './src/config/oidc'; const rootElement = document.getElementById('root'); if (!rootElement) { @@ -10,6 +12,8 @@ if (!rootElement) { const root = ReactDOM.createRoot(rootElement); root.render( - + + + ); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7434bf8..9629381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,11 @@ "canvas-confetti": "^1.9.4", "framer-motion": "^12.26.1", "lucide-react": "^0.562.0", + "oidc-client-ts": "^3.1.0", "peerjs": "^1.5.2", "react": "^19.2.3", "react-dom": "^19.2.3", + "react-oidc-context": "^3.2.0", "recharts": "^3.6.0", "uuid": "^13.0.0" }, @@ -2212,6 +2214,15 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2340,6 +2351,19 @@ "dev": true, "license": "MIT" }, + "node_modules/oidc-client-ts": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", + "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2489,6 +2513,19 @@ "license": "MIT", "peer": true }, + "node_modules/react-oidc-context": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.0.tgz", + "integrity": "sha512-302T/ma4AOVAxrHdYctDSKXjCq9KNHT564XEO2yOPxRfxEP58xa4nz+GQinNl8x7CnEXECSM5JEjQJk3Cr5BvA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "oidc-client-ts": "^3.1.0", + "react": ">=16.14.0" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", diff --git a/package.json b/package.json index 1c0f6eb..77689b7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "recharts": "^3.6.0", "framer-motion": "^12.26.1", "canvas-confetti": "^1.9.4", - "peerjs": "^1.5.2" + "peerjs": "^1.5.2", + "oidc-client-ts": "^3.1.0", + "react-oidc-context": "^3.2.0" }, "devDependencies": { "@types/node": "^22.14.0", diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..3910563 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.env* diff --git a/server/Dockerfile b/server/Dockerfile index b714965..26de127 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -8,7 +8,9 @@ COPY package*.json ./ RUN npm install COPY . . -RUN npm run build +RUN npm run build && cp src/db/schema.sql dist/db/ + +RUN mkdir -p /data EXPOSE 3001 diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..4ea764d --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,2241 @@ +{ + "name": "kaboot-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kaboot-backend", + "version": "1.0.0", + "dependencies": { + "better-sqlite3": "^11.7.0", + "cors": "^2.8.5", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.1.0", + "uuid": "^11.0.5" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.10.7", + "@types/uuid": "^10.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz", + "integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/jwks-rsa/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/jwks-rsa/node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/server/package.json b/server/package.json index 40e2412..6b1d037 100644 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,9 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "tsx --env-file=.env.test tests/api.test.ts", + "test:get-token": "tsx --env-file=.env.test tests/get-token.ts" }, "dependencies": { "better-sqlite3": "^11.7.0", diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts new file mode 100644 index 0000000..b7bfc69 --- /dev/null +++ b/server/src/db/connection.ts @@ -0,0 +1,19 @@ +import Database, { Database as DatabaseType } from 'better-sqlite3'; +import { readFileSync, mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DB_PATH = process.env.DATABASE_PATH || join(__dirname, '../../../data/kaboot.db'); + +mkdirSync(dirname(DB_PATH), { recursive: true }); + +export const db: DatabaseType = new Database(DB_PATH); + +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8'); +db.exec(schema); + +console.log(`Database initialized at ${DB_PATH}`); diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql new file mode 100644 index 0000000..9e4c0dc --- /dev/null +++ b/server/src/db/schema.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + email TEXT, + display_name TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME +); + +CREATE TABLE IF NOT EXISTS quizzes ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT NOT NULL, + source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')), + ai_topic TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS questions ( + id TEXT PRIMARY KEY, + quiz_id TEXT NOT NULL, + text TEXT NOT NULL, + time_limit INTEGER DEFAULT 20, + order_index INTEGER NOT NULL, + FOREIGN KEY (quiz_id) REFERENCES quizzes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS answer_options ( + id TEXT PRIMARY KEY, + question_id TEXT NOT NULL, + text TEXT NOT NULL, + is_correct INTEGER NOT NULL, + shape TEXT NOT NULL CHECK(shape IN ('triangle', 'diamond', 'circle', 'square')), + color TEXT NOT NULL CHECK(color IN ('red', 'blue', 'yellow', 'green')), + reason TEXT, + order_index INTEGER NOT NULL, + FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_quizzes_user ON quizzes(user_id); +CREATE INDEX IF NOT EXISTS idx_questions_quiz ON questions(quiz_id); +CREATE INDEX IF NOT EXISTS idx_options_question ON answer_options(question_id); diff --git a/server/src/index.ts b/server/src/index.ts index f8f34b8..88953ec 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,16 +1,38 @@ -import express from 'express'; +import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; +import { db } from './db/connection.js'; +import quizzesRouter from './routes/quizzes.js'; +import usersRouter from './routes/users.js'; const app = express(); const PORT = process.env.PORT || 3001; -app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173' })); -app.use(express.json()); +app.use(cors({ + origin: process.env.CORS_ORIGIN || 'http://localhost:5173', + credentials: true, +})); +app.use(express.json({ limit: '10mb' })); -app.get('/health', (_req, res) => { +app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); +app.use('/api/quizzes', quizzesRouter); +app.use('/api/users', usersRouter); + +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + console.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + app.listen(PORT, () => { console.log(`Kaboot backend running on port ${PORT}`); + console.log(`Database: ${process.env.DATABASE_PATH || 'default location'}`); + console.log(`CORS origin: ${process.env.CORS_ORIGIN || 'http://localhost:5173'}`); +}); + +process.on('SIGTERM', () => { + console.log('Shutting down...'); + db.close(); + process.exit(0); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts new file mode 100644 index 0000000..a5aa677 --- /dev/null +++ b/server/src/middleware/auth.ts @@ -0,0 +1,82 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import jwksClient from 'jwks-rsa'; + +const OIDC_ISSUER = process.env.OIDC_ISSUER || 'http://localhost:9000/application/o/kaboot/'; +const OIDC_JWKS_URI = process.env.OIDC_JWKS_URI || 'http://localhost:9000/application/o/kaboot/jwks/'; +const OIDC_INTERNAL_JWKS_URI = process.env.OIDC_INTERNAL_JWKS_URI || OIDC_JWKS_URI; + +const client = jwksClient({ + jwksUri: OIDC_INTERNAL_JWKS_URI, + cache: true, + cacheMaxAge: 600000, + rateLimit: true, + jwksRequestsPerMinute: 10, +}); + +function getSigningKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback): void { + if (!header.kid) { + callback(new Error('No kid in token header')); + return; + } + client.getSigningKey(header.kid, (err, key) => { + if (err) { + callback(err); + return; + } + const signingKey = key?.getPublicKey(); + callback(null, signingKey); + }); +} + +export interface AuthenticatedUser { + sub: string; + preferred_username: string; + email?: string; + name?: string; +} + +export interface AuthenticatedRequest extends Request { + user?: AuthenticatedUser; +} + +export function requireAuth( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + res.status(401).json({ error: 'Missing or invalid authorization header' }); + return; + } + + const token = authHeader.slice(7); + + jwt.verify( + token, + getSigningKey, + { + issuer: OIDC_ISSUER, + algorithms: ['RS256'], + }, + (err, decoded) => { + if (err) { + console.error('Token verification failed:', err.message); + res.status(401).json({ error: 'Invalid token', details: err.message }); + return; + } + + const payload = decoded as jwt.JwtPayload; + req.user = { + sub: payload.sub!, + preferred_username: payload.preferred_username || payload.sub!, + email: payload.email, + name: payload.name, + }; + + next(); + } + ); +} diff --git a/server/src/routes/quizzes.ts b/server/src/routes/quizzes.ts new file mode 100644 index 0000000..ed4c06d --- /dev/null +++ b/server/src/routes/quizzes.ts @@ -0,0 +1,225 @@ +import { Router, Response } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { db } from '../db/connection.js'; +import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js'; + +const router = Router(); + +router.use(requireAuth); + +interface QuizBody { + title: string; + source: 'manual' | 'ai_generated'; + aiTopic?: string; + questions: { + text: string; + timeLimit?: number; + options: { + text: string; + isCorrect: boolean; + shape: string; + color: string; + reason?: string; + }[]; + }[]; +} + +router.get('/', (req: AuthenticatedRequest, res: Response) => { + const quizzes = db.prepare(` + SELECT + q.id, + q.title, + q.source, + q.ai_topic as aiTopic, + q.created_at as createdAt, + q.updated_at as updatedAt, + (SELECT COUNT(*) FROM questions WHERE quiz_id = q.id) as questionCount + FROM quizzes q + WHERE q.user_id = ? + ORDER BY q.updated_at DESC + `).all(req.user!.sub); + + res.json(quizzes); +}); + +router.get('/:id', (req: AuthenticatedRequest, res: Response) => { + const quiz = db.prepare(` + SELECT id, title, source, ai_topic as aiTopic, created_at as createdAt, updated_at as updatedAt + FROM quizzes + WHERE id = ? AND user_id = ? + `).get(req.params.id, req.user!.sub) as Record | undefined; + + if (!quiz) { + res.status(404).json({ error: 'Quiz not found' }); + return; + } + + const questions = db.prepare(` + SELECT id, text, time_limit as timeLimit, order_index as orderIndex + FROM questions + WHERE quiz_id = ? + ORDER BY order_index + `).all(quiz.id) as Record[]; + + const questionsWithOptions = questions.map((q) => { + const options = db.prepare(` + SELECT id, text, is_correct as isCorrect, shape, color, reason, order_index as orderIndex + FROM answer_options + WHERE question_id = ? + ORDER BY order_index + `).all(q.id) as Record[]; + + return { + ...q, + options: options.map((o) => ({ + ...o, + isCorrect: Boolean(o.isCorrect), + })), + }; + }); + + res.json({ + ...quiz, + questions: questionsWithOptions, + }); +}); + +router.post('/', (req: AuthenticatedRequest, res: Response) => { + const body = req.body as QuizBody; + const { title, source, aiTopic, questions } = body; + + if (!title || !source || !questions?.length) { + res.status(400).json({ error: 'Missing required fields: title, source, questions' }); + return; + } + + const quizId = uuidv4(); + + const upsertUser = db.prepare(` + INSERT INTO users (id, username, email, display_name, last_login) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET + last_login = CURRENT_TIMESTAMP, + email = COALESCE(excluded.email, users.email), + display_name = COALESCE(excluded.display_name, users.display_name) + `); + + const insertQuiz = db.prepare(` + INSERT INTO quizzes (id, user_id, title, source, ai_topic) + VALUES (?, ?, ?, ?, ?) + `); + + const insertQuestion = db.prepare(` + INSERT INTO questions (id, quiz_id, text, time_limit, order_index) + VALUES (?, ?, ?, ?, ?) + `); + + const insertOption = db.prepare(` + INSERT INTO answer_options (id, question_id, text, is_correct, shape, color, reason, order_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + const transaction = db.transaction(() => { + upsertUser.run( + req.user!.sub, + req.user!.preferred_username, + req.user!.email || null, + req.user!.name || null + ); + + insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null); + + questions.forEach((q, qIdx) => { + const questionId = uuidv4(); + insertQuestion.run(questionId, quizId, q.text, q.timeLimit || 20, qIdx); + + q.options.forEach((o, oIdx) => { + insertOption.run( + uuidv4(), + questionId, + o.text, + o.isCorrect ? 1 : 0, + o.shape, + o.color, + o.reason || null, + oIdx + ); + }); + }); + }); + + transaction(); + res.status(201).json({ id: quizId }); +}); + +router.put('/:id', (req: AuthenticatedRequest, res: Response) => { + const body = req.body as QuizBody; + const { title, questions } = body; + const quizId = req.params.id; + + const existing = db.prepare(` + SELECT id FROM quizzes WHERE id = ? AND user_id = ? + `).get(quizId, req.user!.sub); + + if (!existing) { + res.status(404).json({ error: 'Quiz not found' }); + return; + } + + const updateQuiz = db.prepare(` + UPDATE quizzes SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `); + + const deleteQuestions = db.prepare(`DELETE FROM questions WHERE quiz_id = ?`); + + const insertQuestion = db.prepare(` + INSERT INTO questions (id, quiz_id, text, time_limit, order_index) + VALUES (?, ?, ?, ?, ?) + `); + + const insertOption = db.prepare(` + INSERT INTO answer_options (id, question_id, text, is_correct, shape, color, reason, order_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + const transaction = db.transaction(() => { + updateQuiz.run(title, quizId); + deleteQuestions.run(quizId); + + questions.forEach((q, qIdx) => { + const questionId = uuidv4(); + insertQuestion.run(questionId, quizId, q.text, q.timeLimit || 20, qIdx); + + q.options.forEach((o, oIdx) => { + insertOption.run( + uuidv4(), + questionId, + o.text, + o.isCorrect ? 1 : 0, + o.shape, + o.color, + o.reason || null, + oIdx + ); + }); + }); + }); + + transaction(); + res.json({ id: quizId }); +}); + +router.delete('/:id', (req: AuthenticatedRequest, res: Response) => { + const result = db.prepare(` + DELETE FROM quizzes WHERE id = ? AND user_id = ? + `).run(req.params.id, req.user!.sub); + + if (result.changes === 0) { + res.status(404).json({ error: 'Quiz not found' }); + return; + } + + res.status(204).send(); +}); + +export default router; diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts new file mode 100644 index 0000000..9e4ece6 --- /dev/null +++ b/server/src/routes/users.ts @@ -0,0 +1,32 @@ +import { Router, Response } from 'express'; +import { db } from '../db/connection.js'; +import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js'; + +const router = Router(); + +router.use(requireAuth); + +router.get('/me', (req: AuthenticatedRequest, res: Response) => { + const user = db.prepare(` + SELECT id, username, email, display_name as displayName, created_at as createdAt, last_login as lastLogin + FROM users + WHERE id = ? + `).get(req.user!.sub) as Record | undefined; + + if (!user) { + res.json({ + id: req.user!.sub, + username: req.user!.preferred_username, + email: req.user!.email, + displayName: req.user!.name, + createdAt: null, + lastLogin: null, + isNew: true, + }); + return; + } + + res.json({ ...user, isNew: false }); +}); + +export default router; diff --git a/server/tests/README.md b/server/tests/README.md new file mode 100644 index 0000000..8ce148b --- /dev/null +++ b/server/tests/README.md @@ -0,0 +1,68 @@ +# Kaboot Backend API Tests + +## Getting a Test Token + +Since Authentik uses OAuth2 flows that require browser interaction, you need to obtain a token manually. + +### Method 1: Browser DevTools (Easiest) + +1. Start the Kaboot frontend: `npm run dev` (in root directory) +2. Open `http://localhost:5173` +3. Click "Sign In" and log in with Authentik +4. Open browser DevTools (F12) +5. Go to **Application** > **Local Storage** > `http://localhost:5173` +6. Find the key starting with `oidc.user:` +7. Click on it and find `"access_token"` in the JSON value +8. Copy the token value (without quotes) + +### Method 2: Service Account + +1. Go to Authentik Admin: `http://localhost:9000/if/admin/` +2. Navigate to **Directory** > **Users** +3. Click **Create Service Account** +4. Enter a name (e.g., `kaboot-test-service`) +5. Note the generated username and token +6. Use these credentials: + ```bash + TEST_USERNAME= \ + TEST_PASSWORD= \ + npm run test:get-token + ``` + +## Running Tests + +```bash +cd server +npm install + +# Set the token you obtained +export TEST_TOKEN="your-access-token-here" + +# Run tests +npm run test +``` + +## Test Coverage + +The test suite covers: + +- **Health Check**: Basic server availability +- **Authentication**: 401 without token, 401 with invalid token +- **User API**: GET /api/users/me +- **Quiz CRUD**: + - GET /api/quizzes (list) + - POST /api/quizzes (create) + - GET /api/quizzes/:id (read) + - PUT /api/quizzes/:id (update) + - DELETE /api/quizzes/:id (delete) + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `API_URL` | `http://localhost:3001` | Backend API URL | +| `TEST_TOKEN` | (required) | JWT access token from Authentik | +| `AUTHENTIK_URL` | `http://localhost:9000` | Authentik server URL | +| `CLIENT_ID` | `kaboot-spa` | OAuth2 client ID | +| `TEST_USERNAME` | `kaboottest` | Username for token request | +| `TEST_PASSWORD` | `kaboottest` | Password for token request | diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts new file mode 100644 index 0000000..4b31592 --- /dev/null +++ b/server/tests/api.test.ts @@ -0,0 +1,191 @@ +const API_URL = process.env.API_URL || 'http://localhost:3001'; +const TOKEN = process.env.TEST_TOKEN; + +if (!TOKEN) { + console.error('ERROR: TEST_TOKEN environment variable is required'); + console.log('Run: npm run test:get-token'); + console.log('Then: export TEST_TOKEN=""'); + process.exit(1); +} + +interface TestResult { + name: string; + passed: boolean; + error?: string; +} + +const results: TestResult[] = []; + +async function request( + method: string, + path: string, + body?: unknown, + expectStatus = 200 +): Promise<{ status: number; data: unknown }> { + const response = await fetch(`${API_URL}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${TOKEN}`, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + const data = response.headers.get('content-type')?.includes('application/json') + ? await response.json() + : null; + + if (response.status !== expectStatus) { + throw new Error(`Expected ${expectStatus}, got ${response.status}: ${JSON.stringify(data)}`); + } + + return { status: response.status, data }; +} + +async function test(name: string, fn: () => Promise) { + try { + await fn(); + results.push({ name, passed: true }); + console.log(` ✓ ${name}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + results.push({ name, passed: false, error: message }); + console.log(` ✗ ${name}`); + console.log(` ${message}`); + } +} + +async function runTests() { + console.log('\n=== Kaboot API Tests ===\n'); + console.log(`API: ${API_URL}`); + console.log(''); + + let createdQuizId: string | null = null; + + console.log('Health Check:'); + await test('GET /health returns ok', async () => { + const res = await fetch(`${API_URL}/health`); + const data = await res.json(); + if (data.status !== 'ok') throw new Error('Health check failed'); + }); + + console.log('\nAuth Tests:'); + await test('GET /api/quizzes without token returns 401', async () => { + const res = await fetch(`${API_URL}/api/quizzes`); + if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`); + }); + + await test('GET /api/quizzes with invalid token returns 401', async () => { + const res = await fetch(`${API_URL}/api/quizzes`, { + headers: { Authorization: 'Bearer invalid-token' }, + }); + if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`); + }); + + console.log('\nUser Tests:'); + await test('GET /api/users/me returns user info', async () => { + const { data } = await request('GET', '/api/users/me'); + const user = data as Record; + if (!user.id) throw new Error('Missing user id'); + if (!user.username) throw new Error('Missing username'); + }); + + console.log('\nQuiz CRUD Tests:'); + await test('GET /api/quizzes returns array', async () => { + const { data } = await request('GET', '/api/quizzes'); + if (!Array.isArray(data)) throw new Error('Expected array'); + }); + + await test('POST /api/quizzes creates quiz', async () => { + const quiz = { + title: 'Test Quiz', + source: 'manual', + questions: [ + { + text: 'What is 2 + 2?', + timeLimit: 20, + options: [ + { text: '3', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: '4', isCorrect: true, shape: 'diamond', color: 'blue' }, + { text: '5', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: '6', isCorrect: false, shape: 'square', color: 'green' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', quiz, 201); + const result = data as { id: string }; + if (!result.id) throw new Error('Missing quiz id'); + createdQuizId = result.id; + }); + + await test('GET /api/quizzes/:id returns full quiz', async () => { + if (!createdQuizId) throw new Error('No quiz created'); + const { data } = await request('GET', `/api/quizzes/${createdQuizId}`); + const quiz = data as Record; + if (quiz.title !== 'Test Quiz') throw new Error('Wrong title'); + if (!Array.isArray(quiz.questions)) throw new Error('Missing questions'); + const questions = quiz.questions as Record[]; + if (questions.length !== 1) throw new Error('Wrong question count'); + const q = questions[0]; + if (!Array.isArray(q.options)) throw new Error('Missing options'); + if ((q.options as unknown[]).length !== 4) throw new Error('Wrong option count'); + }); + + await test('PUT /api/quizzes/:id updates quiz', async () => { + if (!createdQuizId) throw new Error('No quiz created'); + const updatedQuiz = { + title: 'Updated Test Quiz', + questions: [ + { + text: 'Updated question?', + timeLimit: 30, + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + { text: 'C', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: 'D', isCorrect: false, shape: 'square', color: 'green' }, + ], + }, + ], + }; + + await request('PUT', `/api/quizzes/${createdQuizId}`, updatedQuiz); + + const { data } = await request('GET', `/api/quizzes/${createdQuizId}`); + const quiz = data as Record; + if (quiz.title !== 'Updated Test Quiz') throw new Error('Title not updated'); + }); + + await test('DELETE /api/quizzes/:id deletes quiz', async () => { + if (!createdQuizId) throw new Error('No quiz created'); + await request('DELETE', `/api/quizzes/${createdQuizId}`, undefined, 204); + }); + + await test('GET /api/quizzes/:id returns 404 for deleted quiz', async () => { + if (!createdQuizId) throw new Error('No quiz created'); + await request('GET', `/api/quizzes/${createdQuizId}`, undefined, 404); + }); + + console.log('\n=== Results ==='); + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + console.log(`Passed: ${passed}/${results.length}`); + console.log(`Failed: ${failed}/${results.length}`); + + if (failed > 0) { + console.log('\nFailed tests:'); + results + .filter((r) => !r.passed) + .forEach((r) => console.log(` - ${r.name}: ${r.error}`)); + process.exit(1); + } + + console.log('\nAll tests passed!'); +} + +runTests().catch((err) => { + console.error('Test runner error:', err); + process.exit(1); +}); diff --git a/server/tests/get-token.ts b/server/tests/get-token.ts new file mode 100644 index 0000000..1e12dbd --- /dev/null +++ b/server/tests/get-token.ts @@ -0,0 +1,79 @@ +const AUTHENTIK_URL = process.env.AUTHENTIK_URL || 'http://localhost:9000'; +const CLIENT_ID = process.env.CLIENT_ID || 'kaboot-spa'; +const APP_SLUG = process.env.APP_SLUG || 'kaboot'; +const USERNAME = process.env.TEST_USERNAME || 'kaboottest'; +const PASSWORD = process.env.TEST_PASSWORD || 'kaboottest'; + +async function getTokenViaPasswordGrant(): Promise { + const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`; + + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + username: USERNAME, + password: PASSWORD, + scope: 'openid profile email', + }); + + console.log(`Token URL: ${tokenUrl}`); + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Password grant failed: ${response.status} - ${error}`); + } + + const data = await response.json(); + return data.access_token; +} + +async function main() { + console.log('Kaboot API Token Generator'); + console.log('==========================\n'); + console.log(`Authentik URL: ${AUTHENTIK_URL}`); + console.log(`Client ID: ${CLIENT_ID}`); + console.log(`Username: ${USERNAME}`); + console.log(''); + + try { + console.log('Attempting password/client_credentials grant...'); + const token = await getTokenViaPasswordGrant(); + console.log('\n✓ Token obtained successfully!\n'); + console.log('=== ACCESS TOKEN ==='); + console.log(token); + console.log('\n=== EXPORT COMMAND ==='); + console.log(`export TEST_TOKEN="${token}"`); + return; + } catch (error) { + console.log(`✗ ${error instanceof Error ? error.message : error}\n`); + } + + console.log('=== MANUAL TOKEN SETUP ===\n'); + console.log('Option 1: Create a Service Account in Authentik'); + console.log(' 1. Go to: Admin > Directory > Users'); + console.log(' 2. Click "Create Service Account"'); + console.log(' 3. Give it a name (e.g., "kaboot-test")'); + console.log(' 4. Copy the username and token generated'); + console.log(' 5. Run: TEST_USERNAME= TEST_PASSWORD= npm run test:get-token\n'); + + console.log('Option 2: Get token from browser'); + console.log(' 1. Log into Kaboot frontend with Authentik'); + console.log(' 2. Open browser DevTools > Application > Local Storage'); + console.log(' 3. Find the oidc.user entry'); + console.log(' 4. Copy the access_token value'); + console.log(' 5. Run: export TEST_TOKEN=""\n'); + + console.log('Option 3: Use Authentik API directly'); + console.log(' 1. Go to: Admin > Directory > Tokens & App passwords'); + console.log(' 2. Create a new token for your user'); + console.log(' 3. Use that token for API testing\n'); + + process.exit(1); +} + +main(); diff --git a/src/config/oidc.ts b/src/config/oidc.ts new file mode 100644 index 0000000..e31341a --- /dev/null +++ b/src/config/oidc.ts @@ -0,0 +1,20 @@ +import { WebStorageStateStore } from 'oidc-client-ts'; + +const AUTHENTIK_URL = import.meta.env.VITE_AUTHENTIK_URL || 'http://localhost:9000'; +const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID || 'kaboot-spa'; +const APP_SLUG = import.meta.env.VITE_OIDC_APP_SLUG || 'kaboot'; + +export const oidcConfig = { + authority: `${AUTHENTIK_URL}/application/o/${APP_SLUG}/`, + client_id: CLIENT_ID, + redirect_uri: `${window.location.origin}/callback`, + post_logout_redirect_uri: window.location.origin, + response_type: 'code', + scope: 'openid profile email offline_access', + automaticSilentRenew: true, + loadUserInfo: true, + userStore: new WebStorageStateStore({ store: window.localStorage }), + onSigninCallback: () => { + window.history.replaceState({}, document.title, window.location.pathname); + }, +}; From 0cc099c00c60b5aa1f9d164604678e9ce5fed4b1 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 13 Jan 2026 15:27:46 -0700 Subject: [PATCH 03/10] Fix tests --- server/package.json | 3 +- server/tests/get-token.ts | 121 ++++++++++++++++++++++++++------------ server/tests/run-tests.ts | 83 ++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 40 deletions(-) create mode 100644 server/tests/run-tests.ts diff --git a/server/package.json b/server/package.json index 6b1d037..8ba79da 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,8 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "test": "tsx --env-file=.env.test tests/api.test.ts", + "test": "tsx --env-file=.env.test tests/run-tests.ts", + "test:only": "tsx --env-file=.env.test tests/api.test.ts", "test:get-token": "tsx --env-file=.env.test tests/get-token.ts" }, "dependencies": { diff --git a/server/tests/get-token.ts b/server/tests/get-token.ts index 1e12dbd..ecb231a 100644 --- a/server/tests/get-token.ts +++ b/server/tests/get-token.ts @@ -1,22 +1,21 @@ const AUTHENTIK_URL = process.env.AUTHENTIK_URL || 'http://localhost:9000'; const CLIENT_ID = process.env.CLIENT_ID || 'kaboot-spa'; -const APP_SLUG = process.env.APP_SLUG || 'kaboot'; -const USERNAME = process.env.TEST_USERNAME || 'kaboottest'; -const PASSWORD = process.env.TEST_PASSWORD || 'kaboottest'; +const CLIENT_SECRET = process.env.CLIENT_SECRET || ''; +const USERNAME = process.env.TEST_USERNAME || ''; +const PASSWORD = process.env.TEST_PASSWORD || ''; -async function getTokenViaPasswordGrant(): Promise { +async function getTokenWithClientSecret(): Promise { + if (!CLIENT_SECRET) throw new Error('CLIENT_SECRET not set'); + const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`; - const params = new URLSearchParams({ grant_type: 'client_credentials', client_id: CLIENT_ID, - username: USERNAME, - password: PASSWORD, + client_secret: CLIENT_SECRET, scope: 'openid profile email', }); - console.log(`Token URL: ${tokenUrl}`); - + console.log(` Trying client_credentials with client_secret...`); const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -25,7 +24,35 @@ async function getTokenViaPasswordGrant(): Promise { if (!response.ok) { const error = await response.text(); - throw new Error(`Password grant failed: ${response.status} - ${error}`); + throw new Error(`${response.status} - ${error}`); + } + + const data = await response.json(); + return data.access_token; +} + +async function getTokenWithServiceAccount(): Promise { + if (!USERNAME || !PASSWORD) throw new Error('USERNAME and PASSWORD not set'); + + const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`; + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + username: USERNAME, + password: PASSWORD, + scope: 'openid profile email', + }); + + console.log(` Trying client_credentials with username/password...`); + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`${response.status} - ${error}`); } const data = await response.json(); @@ -37,41 +64,57 @@ async function main() { console.log('==========================\n'); console.log(`Authentik URL: ${AUTHENTIK_URL}`); console.log(`Client ID: ${CLIENT_ID}`); - console.log(`Username: ${USERNAME}`); console.log(''); - try { - console.log('Attempting password/client_credentials grant...'); - const token = await getTokenViaPasswordGrant(); - console.log('\n✓ Token obtained successfully!\n'); - console.log('=== ACCESS TOKEN ==='); - console.log(token); - console.log('\n=== EXPORT COMMAND ==='); - console.log(`export TEST_TOKEN="${token}"`); - return; - } catch (error) { - console.log(`✗ ${error instanceof Error ? error.message : error}\n`); + let token: string | null = null; + + if (CLIENT_SECRET) { + try { + token = await getTokenWithClientSecret(); + console.log(' ✓ Success!\n'); + } catch (error) { + console.log(` ✗ Failed: ${error instanceof Error ? error.message : error}\n`); + } } - console.log('=== MANUAL TOKEN SETUP ===\n'); - console.log('Option 1: Create a Service Account in Authentik'); - console.log(' 1. Go to: Admin > Directory > Users'); - console.log(' 2. Click "Create Service Account"'); - console.log(' 3. Give it a name (e.g., "kaboot-test")'); - console.log(' 4. Copy the username and token generated'); - console.log(' 5. Run: TEST_USERNAME= TEST_PASSWORD= npm run test:get-token\n'); + if (!token && USERNAME && PASSWORD) { + try { + token = await getTokenWithServiceAccount(); + console.log(' ✓ Success!\n'); + } catch (error) { + console.log(` ✗ Failed: ${error instanceof Error ? error.message : error}\n`); + } + } - console.log('Option 2: Get token from browser'); - console.log(' 1. Log into Kaboot frontend with Authentik'); - console.log(' 2. Open browser DevTools > Application > Local Storage'); - console.log(' 3. Find the oidc.user entry'); - console.log(' 4. Copy the access_token value'); - console.log(' 5. Run: export TEST_TOKEN=""\n'); + if (token) { + console.log('=== ACCESS TOKEN ==='); + console.log(token); + console.log('\n=== FOR .env.test ==='); + console.log(`TEST_TOKEN=${token}`); + return; + } - console.log('Option 3: Use Authentik API directly'); - console.log(' 1. Go to: Admin > Directory > Tokens & App passwords'); - console.log(' 2. Create a new token for your user'); - console.log(' 3. Use that token for API testing\n'); + console.log('=== SETUP INSTRUCTIONS ===\n'); + console.log('Method 1: Client Secret (Recommended for testing)\n'); + console.log(' 1. Go to Authentik Admin: http://localhost:9000/if/admin/'); + console.log(' 2. Navigate to: Applications → Providers → Kaboot OAuth2'); + console.log(' 3. Change "Client type" from "Public" to "Confidential"'); + console.log(' 4. Copy the "Client Secret" value'); + console.log(' 5. Add to server/.env.test:'); + console.log(' CLIENT_SECRET=\n'); + + console.log('Method 2: Service Account + App Password\n'); + console.log(' 1. Go to Authentik Admin: http://localhost:9000/if/admin/'); + console.log(' 2. Navigate to: Directory → Users'); + console.log(' 3. Click "Create Service Account"'); + console.log(' 4. Name it (e.g., "kaboot-test")'); + console.log(' 5. After creation, click on the user → "App passwords" tab'); + console.log(' 6. Create a new app password, copy the token'); + console.log(' 7. Bind the service account to Kaboot app:'); + console.log(' Applications → Kaboot → Policy/Group/User Bindings → Bind existing user'); + console.log(' 8. Add to server/.env.test:'); + console.log(' TEST_USERNAME='); + console.log(' TEST_PASSWORD=\n'); process.exit(1); } diff --git a/server/tests/run-tests.ts b/server/tests/run-tests.ts new file mode 100644 index 0000000..5e33360 --- /dev/null +++ b/server/tests/run-tests.ts @@ -0,0 +1,83 @@ +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const AUTHENTIK_URL = process.env.AUTHENTIK_URL || 'http://localhost:9000'; +const CLIENT_ID = process.env.CLIENT_ID || 'kaboot-spa'; +const USERNAME = process.env.TEST_USERNAME || ''; +const PASSWORD = process.env.TEST_PASSWORD || ''; + +async function getToken(): Promise { + if (!USERNAME || !PASSWORD) { + throw new Error( + 'TEST_USERNAME and TEST_PASSWORD must be set in .env.test\n' + + 'See tests/README.md for setup instructions.' + ); + } + + const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`; + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + username: USERNAME, + password: PASSWORD, + scope: 'openid profile email', + }); + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get token: ${response.status} - ${error}`); + } + + const data = await response.json(); + return data.access_token; +} + +async function runTests(token: string): Promise { + return new Promise((resolve) => { + const __dirname = dirname(fileURLToPath(import.meta.url)); + const testFile = join(__dirname, 'api.test.ts'); + + const child = spawn(process.execPath, [ + '--import', 'tsx', + testFile + ], { + env: { + ...process.env, + TEST_TOKEN: token, + }, + stdio: 'inherit', + }); + + child.on('close', (code) => { + resolve(code ?? 1); + }); + }); +} + +async function main() { + console.log('Kaboot API Test Runner'); + console.log('======================\n'); + + console.log('Obtaining access token from Authentik...'); + let token: string; + try { + token = await getToken(); + console.log(' Token obtained successfully.\n'); + } catch (error) { + console.error(` Failed: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + + console.log('Running API tests...\n'); + const exitCode = await runTests(token); + process.exit(exitCode); +} + +main(); From 66f15b49b270d5771ce03c3540459fd9c00a0110 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 13 Jan 2026 15:39:02 -0700 Subject: [PATCH 04/10] Phase 4 complete --- App.tsx | 2 + IMPLEMENTATION_PLAN.md | 59 +++++---- components/Landing.tsx | 46 ++++++- components/QuizLibrary.tsx | 190 +++++++++++++++++++++++++++ docs/AUTHENTIK_SETUP.md | 63 ++++++++- hooks/useGame.ts | 6 +- hooks/useQuizLibrary.ts | 103 +++++++++++++++ server/tests/api.test.ts | 258 +++++++++++++++++++++++++++++++++++++ types.ts | 20 +++ 9 files changed, 710 insertions(+), 37 deletions(-) create mode 100644 components/QuizLibrary.tsx create mode 100644 hooks/useQuizLibrary.ts diff --git a/App.tsx b/App.tsx index cbfe1bf..52680ce 100644 --- a/App.tsx +++ b/App.tsx @@ -46,6 +46,7 @@ function App() { startQuizGen, startManualCreation, finalizeManualQuiz, + loadSavedQuiz, joinGame, startGame, handleAnswer, @@ -76,6 +77,7 @@ function App() { ` modal when open - - [ ] Handle quiz load: call `onLoadQuiz` prop with loaded quiz +- [x] Modify `components/Landing.tsx`: + - [x] Add "My Quizzes" button (only visible when authenticated) + - [x] Add state for quiz library modal visibility + - [x] Render `` modal when open + - [x] Handle quiz load: call `onLoadQuiz` prop with loaded quiz ### 4.4 Types Update -- [ ] Modify `src/types.ts`: - - [ ] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates) - - [ ] Add `QuizListItem` interface (for list view) - - [ ] Add `QuizSource` type: `'manual' | 'ai_generated'` +- [x] Modify `types.ts`: + - [x] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates) + - [x] Add `QuizListItem` interface (for list view) + - [x] Add `QuizSource` type: `'manual' | 'ai_generated'` + +### 4.5 Game Hook Integration +- [x] Modify `hooks/useGame.ts`: + - [x] Add `loadSavedQuiz(quiz)` function + - [x] Export for App.tsx to consume --- @@ -403,7 +406,7 @@ kaboot/ | Phase 1 | **COMPLETE** | Docker Compose, .env, setup script, Authentik docs | | Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD | | Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch | -| Phase 4 | Not Started | | +| Phase 4 | **COMPLETE** | useQuizLibrary hook, QuizLibrary modal, Landing integration | | Phase 5 | Not Started | | | Phase 6 | Not Started | | | Phase 7 | Not Started | | diff --git a/components/Landing.tsx b/components/Landing.tsx index de17fdc..c552466 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -1,21 +1,36 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; -import { BrainCircuit, Loader2, Users, Play, PenTool } from 'lucide-react'; +import { BrainCircuit, Loader2, Play, PenTool, BookOpen } from 'lucide-react'; +import { useAuth } from 'react-oidc-context'; import { AuthButton } from './AuthButton'; +import { QuizLibrary } from './QuizLibrary'; +import { useQuizLibrary } from '../hooks/useQuizLibrary'; +import type { Quiz } from '../types'; interface LandingProps { onGenerate: (topic: string) => void; onCreateManual: () => void; + onLoadQuiz: (quiz: Quiz) => void; onJoin: (pin: string, name: string) => void; isLoading: boolean; error: string | null; } -export const Landing: React.FC = ({ onGenerate, onCreateManual, onJoin, isLoading, error }) => { +export const Landing: React.FC = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => { + const auth = useAuth(); const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST'); const [topic, setTopic] = useState(''); const [pin, setPin] = useState(''); const [name, setName] = useState(''); + const [libraryOpen, setLibraryOpen] = useState(false); + + const { quizzes, loading: libraryLoading, error: libraryError, fetchQuizzes, loadQuiz, deleteQuiz } = useQuizLibrary(); + + useEffect(() => { + if (libraryOpen && auth.isAuthenticated) { + fetchQuizzes(); + } + }, [libraryOpen, auth.isAuthenticated, fetchQuizzes]); const handleHostSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -27,6 +42,12 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on if (pin.trim() && name.trim()) onJoin(pin, name); }; + const handleLoadQuiz = async (id: string) => { + const quiz = await loadQuiz(id); + setLibraryOpen(false); + onLoadQuiz(quiz); + }; + return (
@@ -93,6 +114,15 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on > Create Manually + + {auth.isAuthenticated && ( + + )}
) : (
@@ -126,6 +156,16 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on )} + + setLibraryOpen(false)} + quizzes={quizzes} + loading={libraryLoading} + error={libraryError} + onLoadQuiz={handleLoadQuiz} + onDeleteQuiz={deleteQuiz} + />
); }; diff --git a/components/QuizLibrary.tsx b/components/QuizLibrary.tsx new file mode 100644 index 0000000..9a0e4b7 --- /dev/null +++ b/components/QuizLibrary.tsx @@ -0,0 +1,190 @@ +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar } from 'lucide-react'; +import { QuizListItem } from '../types'; + +interface QuizLibraryProps { + isOpen: boolean; + onClose: () => void; + quizzes: QuizListItem[]; + loading: boolean; + error: string | null; + onLoadQuiz: (id: string) => void; + onDeleteQuiz: (id: string) => void; +} + +export const QuizLibrary: React.FC = ({ + isOpen, + onClose, + quizzes, + loading, + error, + onLoadQuiz, + onDeleteQuiz, +}) => { + const [deletingId, setDeletingId] = useState(null); + + const handleDeleteClick = (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + setDeletingId(id); + }; + + const confirmDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + if (deletingId) { + onDeleteQuiz(deletingId); + setDeletingId(null); + } + }; + + const cancelDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + setDeletingId(null); + }; + + const formatDate = (dateString: string) => { + try { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } catch (e) { + return dateString; + } + }; + + return ( + + {isOpen && ( + <> + + e.stopPropagation()} + className="bg-white w-full max-w-2xl max-h-[80vh] flex flex-col rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 relative overflow-hidden" + > +
+
+

My Library

+

Select a quiz to play

+
+ +
+ +
+ {loading && ( +
+ +

Loading your quizzes...

+
+ )} + + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && !error && quizzes.length === 0 && ( +
+
+ +
+

No saved quizzes yet

+

Create or generate a quiz to save it here!

+
+ )} + + {!loading && !error && quizzes.map((quiz) => ( + onLoadQuiz(quiz.id)} + > +
+
+
+ {quiz.source === 'ai_generated' ? ( + + AI + + ) : ( + + Manual + + )} + + {formatDate(quiz.createdAt)} + +
+ +

+ {quiz.title} +

+ +

+ {quiz.questionCount} question{quiz.questionCount !== 1 ? 's' : ''} + {quiz.aiTopic && • Topic: {quiz.aiTopic}} +

+
+ +
+ {deletingId === quiz.id ? ( +
e.stopPropagation()}> + + +
+ ) : ( + <> + +
+ +
+ + )} +
+
+
+ ))} +
+
+
+ + )} +
+ ); +}; diff --git a/docs/AUTHENTIK_SETUP.md b/docs/AUTHENTIK_SETUP.md index 60a6694..940c1b9 100644 --- a/docs/AUTHENTIK_SETUP.md +++ b/docs/AUTHENTIK_SETUP.md @@ -93,20 +93,73 @@ Note these endpoints (you'll need them for frontend configuration): 2. You should see a JSON response with all OIDC endpoints -## Step 6: Create a Test User (Optional) +## Step 6: Create a Test User + +Create a regular user for manual browser testing. 1. Go to **Directory** > **Users** 2. Click **Create** 3. Fill in user details: - - Username: `testuser` - - Name: `Test User` - - Email: `test@example.com` + | Field | Value | + |-------|-------| + | Username | `kaboottest` | + | Name | `Kaboot Test` | + | Email | `kaboottest@test.com` | 4. After creation, click on the user and go to the **Credentials** tab -5. Click **Set password** to create a password +5. Click **Set password** and set it to `kaboottest` + +6. **Bind the user to the Kaboot application**: + - Go to **Applications** > **Applications** > **Kaboot** + - Click the **Policy / Group / User Bindings** tab + - Click **Bind existing user** + - Select `kaboottest` and click **Bind** + +## Step 7: Create a Service Account for API Testing + +Create a service account that can obtain tokens programmatically for automated tests. + +1. Go to **Directory** > **Users** + +2. Click **Create Service Account** + +3. Fill in details: + | Field | Value | + |-------|-------| + | Username | `kaboot-test-service` | + | Create group | Unchecked | + +4. Click **Create** + +5. **Create an App Password** for the service account: + - Click on the newly created `kaboot-test-service` user + - Go to the **App passwords** tab + - Click **Create App Password** + - Name it `api-tests` + - Copy the generated password (you won't see it again!) + +6. **Bind the service account to the Kaboot application**: + - Go to **Applications** > **Applications** > **Kaboot** + - Click the **Policy / Group / User Bindings** tab + - Click **Bind existing user** + - Select `kaboot-test-service` and click **Bind** + +7. **Save credentials to `server/.env.test`**: + ```bash + TEST_USERNAME=kaboot-test-service + TEST_PASSWORD= + ``` + +8. **Verify token generation works**: + ```bash + cd server + npm run test:get-token + ``` + + You should see "Token obtained successfully" and the access token printed. ## Environment Variables diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 2b9b6ac..63df37e 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -64,6 +64,10 @@ export const useGame = () => { initializeHostGame(manualQuiz); }; + const loadSavedQuiz = (savedQuiz: Quiz) => { + initializeHostGame(savedQuiz); + }; + // We use a ref to hold the current handleHostData function // This prevents stale closures in the PeerJS event listeners const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {}); @@ -379,6 +383,6 @@ export const useGame = () => { return { role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, - startQuizGen, startManualCreation, finalizeManualQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion + startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion }; }; \ No newline at end of file diff --git a/hooks/useQuizLibrary.ts b/hooks/useQuizLibrary.ts new file mode 100644 index 0000000..da54024 --- /dev/null +++ b/hooks/useQuizLibrary.ts @@ -0,0 +1,103 @@ +import { useState, useCallback } from 'react'; +import { useAuthenticatedFetch } from './useAuthenticatedFetch'; +import type { Quiz, QuizSource, SavedQuiz, QuizListItem } from '../types'; + +interface UseQuizLibraryReturn { + quizzes: QuizListItem[]; + loading: boolean; + error: string | null; + fetchQuizzes: () => Promise; + loadQuiz: (id: string) => Promise; + saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise; + deleteQuiz: (id: string) => Promise; +} + +export const useQuizLibrary = (): UseQuizLibraryReturn => { + const { authFetch, isAuthenticated } = useAuthenticatedFetch(); + const [quizzes, setQuizzes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchQuizzes = useCallback(async () => { + if (!isAuthenticated) return; + + setLoading(true); + setError(null); + + try { + const response = await authFetch('/api/quizzes'); + if (!response.ok) { + throw new Error('Failed to fetch quizzes'); + } + const data = await response.json(); + setQuizzes(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch quizzes'); + } finally { + setLoading(false); + } + }, [authFetch, isAuthenticated]); + + const loadQuiz = useCallback(async (id: string): Promise => { + const response = await authFetch(`/api/quizzes/${id}`); + if (!response.ok) { + throw new Error('Failed to load quiz'); + } + return response.json(); + }, [authFetch]); + + const saveQuiz = useCallback(async ( + quiz: Quiz, + source: QuizSource, + aiTopic?: string + ): Promise => { + const response = await authFetch('/api/quizzes', { + method: 'POST', + body: JSON.stringify({ + title: quiz.title, + source, + aiTopic, + questions: quiz.questions.map(q => ({ + text: q.text, + timeLimit: q.timeLimit, + options: q.options.map(o => ({ + text: o.text, + isCorrect: o.isCorrect, + shape: o.shape, + color: o.color, + reason: o.reason, + })), + })), + }), + }); + + if (!response.ok) { + throw new Error('Failed to save quiz'); + } + + const data = await response.json(); + return data.id; + }, [authFetch]); + + const deleteQuiz = useCallback(async (id: string): Promise => { + const response = await authFetch(`/api/quizzes/${id}`, { + method: 'DELETE', + }); + + if (!response.ok && response.status !== 204) { + throw new Error('Failed to delete quiz'); + } + + setQuizzes(prev => prev.filter(q => q.id !== id)); + }, [authFetch]); + + return { + quizzes, + loading, + error, + fetchQuizzes, + loadQuiz, + saveQuiz, + deleteQuiz, + }; +}; diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index 4b31592..69d9d79 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -168,6 +168,264 @@ async function runTests() { await request('GET', `/api/quizzes/${createdQuizId}`, undefined, 404); }); + console.log('\nQuiz Validation Tests:'); + await test('POST /api/quizzes without title returns 400', async () => { + const invalidQuiz = { + source: 'manual', + questions: [ + { + text: 'Question?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + ], + }, + ], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes without source returns 400', async () => { + const invalidQuiz = { + title: 'Missing Source Quiz', + questions: [ + { + text: 'Question?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + ], + }, + ], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes without questions returns 400', async () => { + const invalidQuiz = { + title: 'No Questions Quiz', + source: 'manual', + questions: [], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes with empty body returns 400', async () => { + await request('POST', '/api/quizzes', {}, 400); + }); + + console.log('\nQuiz Not Found Tests:'); + await test('GET /api/quizzes/:id with non-existent ID returns 404', async () => { + await request('GET', '/api/quizzes/non-existent-uuid-12345', undefined, 404); + }); + + await test('PUT /api/quizzes/:id with non-existent ID returns 404', async () => { + const quiz = { + title: 'Update Non-Existent', + questions: [ + { + text: 'Q?', + options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }], + }, + ], + }; + await request('PUT', '/api/quizzes/non-existent-uuid-12345', quiz, 404); + }); + + await test('DELETE /api/quizzes/:id with non-existent ID returns 404', async () => { + await request('DELETE', '/api/quizzes/non-existent-uuid-12345', undefined, 404); + }); + + console.log('\nQuiz Source Types Tests:'); + let aiQuizId: string | null = null; + + await test('POST /api/quizzes with ai_generated source and aiTopic', async () => { + const aiQuiz = { + title: 'AI Generated Quiz', + source: 'ai_generated', + aiTopic: 'Space Exploration', + questions: [ + { + text: 'What planet is known as the Red Planet?', + timeLimit: 20, + options: [ + { text: 'Venus', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: 'Mars', isCorrect: true, shape: 'diamond', color: 'blue' }, + { text: 'Jupiter', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: 'Saturn', isCorrect: false, shape: 'square', color: 'green' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', aiQuiz, 201); + const result = data as { id: string }; + if (!result.id) throw new Error('Missing quiz id'); + aiQuizId = result.id; + }); + + await test('GET /api/quizzes/:id returns aiTopic for AI quiz', async () => { + if (!aiQuizId) throw new Error('No AI quiz created'); + const { data } = await request('GET', `/api/quizzes/${aiQuizId}`); + const quiz = data as Record; + if (quiz.source !== 'ai_generated') throw new Error('Wrong source'); + if (quiz.aiTopic !== 'Space Exploration') throw new Error('Missing or wrong aiTopic'); + }); + + await test('GET /api/quizzes list includes source and questionCount', async () => { + const { data } = await request('GET', '/api/quizzes'); + const quizzes = data as Record[]; + if (quizzes.length === 0) throw new Error('Expected at least one quiz'); + const quiz = quizzes.find((q) => q.id === aiQuizId); + if (!quiz) throw new Error('AI quiz not in list'); + if (quiz.source !== 'ai_generated') throw new Error('Missing source in list'); + if (typeof quiz.questionCount !== 'number') throw new Error('Missing questionCount'); + if (quiz.questionCount !== 1) throw new Error('Wrong questionCount'); + }); + + await test('DELETE cleanup AI quiz', async () => { + if (!aiQuizId) throw new Error('No AI quiz to delete'); + await request('DELETE', `/api/quizzes/${aiQuizId}`, undefined, 204); + }); + + console.log('\nQuiz with Multiple Questions Tests:'); + let multiQuizId: string | null = null; + + await test('POST /api/quizzes with multiple questions', async () => { + const multiQuiz = { + title: 'Multi-Question Quiz', + source: 'manual', + questions: [ + { + text: 'Question 1?', + timeLimit: 15, + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + { + text: 'Question 2?', + timeLimit: 25, + options: [ + { text: 'X', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: 'Y', isCorrect: true, shape: 'square', color: 'green' }, + ], + }, + { + text: 'Question 3?', + timeLimit: 30, + options: [ + { text: 'P', isCorrect: false, shape: 'triangle', color: 'red', reason: 'Wrong because...' }, + { text: 'Q', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct because...' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', multiQuiz, 201); + const result = data as { id: string }; + multiQuizId = result.id; + }); + + await test('GET /api/quizzes/:id returns all questions with correct order', async () => { + if (!multiQuizId) throw new Error('No multi-question quiz created'); + const { data } = await request('GET', `/api/quizzes/${multiQuizId}`); + const quiz = data as { questions: { text: string; timeLimit: number; options: { reason?: string }[] }[] }; + if (quiz.questions.length !== 3) throw new Error(`Expected 3 questions, got ${quiz.questions.length}`); + if (quiz.questions[0].text !== 'Question 1?') throw new Error('Wrong order for Q1'); + if (quiz.questions[1].text !== 'Question 2?') throw new Error('Wrong order for Q2'); + if (quiz.questions[2].text !== 'Question 3?') throw new Error('Wrong order for Q3'); + if (quiz.questions[0].timeLimit !== 15) throw new Error('Wrong timeLimit for Q1'); + if (quiz.questions[2].options[1].reason !== 'Correct because...') throw new Error('Missing reason field'); + }); + + await test('GET /api/quizzes shows correct questionCount for multi-question quiz', async () => { + const { data } = await request('GET', '/api/quizzes'); + const quizzes = data as Record[]; + const quiz = quizzes.find((q) => q.id === multiQuizId); + if (!quiz) throw new Error('Multi-question quiz not in list'); + if (quiz.questionCount !== 3) throw new Error(`Expected questionCount 3, got ${quiz.questionCount}`); + }); + + await test('PUT /api/quizzes/:id replaces all questions', async () => { + if (!multiQuizId) throw new Error('No multi-question quiz created'); + const updatedQuiz = { + title: 'Updated Multi Quiz', + questions: [ + { + text: 'Only One Question Now', + timeLimit: 10, + options: [ + { text: 'Solo', isCorrect: true, shape: 'triangle', color: 'red' }, + ], + }, + ], + }; + + await request('PUT', `/api/quizzes/${multiQuizId}`, updatedQuiz); + const { data } = await request('GET', `/api/quizzes/${multiQuizId}`); + const quiz = data as { title: string; questions: unknown[] }; + if (quiz.title !== 'Updated Multi Quiz') throw new Error('Title not updated'); + if (quiz.questions.length !== 1) throw new Error(`Expected 1 question after update, got ${quiz.questions.length}`); + }); + + await test('DELETE cleanup multi-question quiz', async () => { + if (!multiQuizId) throw new Error('No multi-question quiz to delete'); + await request('DELETE', `/api/quizzes/${multiQuizId}`, undefined, 204); + }); + + console.log('\nTimestamp Tests:'); + let timestampQuizId: string | null = null; + + await test('POST /api/quizzes returns quiz with timestamps', async () => { + const quiz = { + title: 'Timestamp Test Quiz', + source: 'manual', + questions: [ + { + text: 'Timestamp Q?', + options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }], + }, + ], + }; + + const { data: createData } = await request('POST', '/api/quizzes', quiz, 201); + timestampQuizId = (createData as { id: string }).id; + + const { data } = await request('GET', `/api/quizzes/${timestampQuizId}`); + const result = data as Record; + if (!result.createdAt) throw new Error('Missing createdAt'); + if (!result.updatedAt) throw new Error('Missing updatedAt'); + }); + + await test('PUT /api/quizzes/:id updates updatedAt timestamp', async () => { + if (!timestampQuizId) throw new Error('No timestamp quiz created'); + + const { data: beforeData } = await request('GET', `/api/quizzes/${timestampQuizId}`); + const beforeUpdatedAt = (beforeData as Record).updatedAt; + + await new Promise((resolve) => setTimeout(resolve, 1100)); + + await request('PUT', `/api/quizzes/${timestampQuizId}`, { + title: 'Updated Timestamp Quiz', + questions: [ + { + text: 'Updated Q?', + options: [{ text: 'B', isCorrect: true, shape: 'diamond', color: 'blue' }], + }, + ], + }); + + const { data: afterData } = await request('GET', `/api/quizzes/${timestampQuizId}`); + const afterUpdatedAt = (afterData as Record).updatedAt; + + if (beforeUpdatedAt === afterUpdatedAt) throw new Error('updatedAt should have changed'); + }); + + await test('DELETE cleanup timestamp quiz', async () => { + if (!timestampQuizId) throw new Error('No timestamp quiz to delete'); + await request('DELETE', `/api/quizzes/${timestampQuizId}`, undefined, 204); + }); + console.log('\n=== Results ==='); const passed = results.filter((r) => r.passed).length; const failed = results.filter((r) => !r.passed).length; diff --git a/types.ts b/types.ts index ea2601a..6fc1adc 100644 --- a/types.ts +++ b/types.ts @@ -31,6 +31,26 @@ export interface Quiz { questions: Question[]; } +export type QuizSource = 'manual' | 'ai_generated'; + +export interface SavedQuiz extends Quiz { + id: string; + source: QuizSource; + aiTopic?: string; + createdAt: string; + updatedAt: string; +} + +export interface QuizListItem { + id: string; + title: string; + source: QuizSource; + aiTopic?: string; + questionCount: number; + createdAt: string; + updatedAt: string; +} + export interface Player { id: string; name: string; From 342ff60b70d1ca57fc6754499cdb5b96c20cb48b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 13 Jan 2026 16:18:46 -0700 Subject: [PATCH 05/10] Add user setup config --- components/Landing.tsx | 13 ++- docker-compose.yml | 2 +- docs/AUTHENTIK_SETUP.md | 163 ++++++++++++++++++++++++++++++++- hooks/useAuthenticatedFetch.ts | 51 +++++++++-- index.tsx | 12 ++- src/config/oidc.ts | 5 +- vite.config.ts | 2 +- 7 files changed, 230 insertions(+), 18 deletions(-) diff --git a/components/Landing.tsx b/components/Landing.tsx index c552466..6b90774 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -43,9 +43,16 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on }; const handleLoadQuiz = async (id: string) => { - const quiz = await loadQuiz(id); - setLibraryOpen(false); - onLoadQuiz(quiz); + try { + const quiz = await loadQuiz(id); + setLibraryOpen(false); + onLoadQuiz(quiz); + } catch (err) { + if (err instanceof Error && err.message.includes('redirecting')) { + return; + } + console.error('Failed to load quiz:', err); + } }; return ( diff --git a/docker-compose.yml b/docker-compose.yml index 619a6e9..0c001fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,7 +108,7 @@ services: OIDC_ISSUER: http://localhost:9000/application/o/kaboot/ OIDC_JWKS_URI: http://localhost:9000/application/o/kaboot/jwks/ OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/ - CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000} + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173} volumes: - kaboot-data:/data ports: diff --git a/docs/AUTHENTIK_SETUP.md b/docs/AUTHENTIK_SETUP.md index 940c1b9..b18ea0c 100644 --- a/docs/AUTHENTIK_SETUP.md +++ b/docs/AUTHENTIK_SETUP.md @@ -70,7 +70,168 @@ This guide walks through configuring Authentik as the OAuth2/OIDC identity provi 7. Click **Submit** -## Step 4: Verify OIDC Endpoints +## Step 4: Enable User Registration (Sign Up) + +By default, Authentik only shows a login form. To allow users to sign up, you need to create an enrollment flow and link it. + +### Step 4.1: Create the Enrollment Prompt Stage + +1. Go to **Flows and Stages** > **Stages** + +2. Click **Create** + +3. Select **Prompt Stage** and click **Next** + +4. Configure: + | Field | Value | + |-------|-------| + | Name | `enrollment-prompt` | + +5. In the **Fields** section, move these to the **Selected** side: + - `default-source-enrollment-field-username` (username) + - `default-user-settings-field-email` (email) + - `default-password-change-field-password` (password) + - `default-password-change-field-password-repeat` (password_repeat) + +6. (Optional) In **Validation policies**, select `password-complexity` if you created it in Step 4.2 + +7. Click **Finish** + +### Step 4.2: (Optional) Create Password Complexity Policy + +1. Go to **Customisation** > **Policies** + +2. Click **Create** and select **Password Policy** + +3. Configure: + | Field | Value | + |-------|-------| + | Name | `password-complexity` | + | Password field | `password` | + | Minimum length | `8` | + | Amount of uppercase characters | `1` | + | Amount of lowercase characters | `1` | + | Amount of digits | `1` | + +4. Click **Finish** + +You'll add this to the enrollment prompt stage later. + +### Step 4.3: Create a Group for Kaboot Users + +1. Go to **Directory** > **Groups** + +2. Click **Create** + +3. Configure: + | Field | Value | + |-------|-------| + | Name | `kaboot-users` | + +4. Click **Create** + +### Step 4.4: Create the User Write Stage + +1. Go to **Flows and Stages** > **Stages** + +2. Click **Create** + +3. Select **User Write Stage** and click **Next** + +4. Configure: + | Field | Value | + |-------|-------| + | Name | `enrollment-user-write` | + | User creation mode | `Create users when required` | + | Create users as inactive | Unchecked | + | Group | `kaboot-users` | + +5. Click **Finish** + +### Step 4.5: Create the User Login Stage + +1. Go to **Flows and Stages** > **Stages** + +2. Click **Create** + +3. Select **User Login Stage** and click **Next** + +4. Configure: + | Field | Value | + |-------|-------| + | Name | `enrollment-user-login` | + | Session duration | `hours=24` | + | Stay signed in offset | `days=30` | + | Network binding | `No binding` | + | GeoIP binding | `No binding` | + +5. Click **Finish** + +### Step 4.6: Create the Enrollment Flow + +1. Go to **Flows and Stages** > **Flows** + +2. Click **Create** + +3. Configure: + | Field | Value | + |-------|-------| + | Name | `Enrollment Flow` | + | Title | `Sign Up` | + | Slug | `enrollment-flow` | + | Designation | `Enrollment` | + | Authentication | `No requirement` | + +4. Click **Create** + +5. Click on the newly created `enrollment-flow` + +6. Go to the **Stage Bindings** tab + +7. Click **Bind existing stage** and add stages in this order: + | Stage | Order | + |-------|-------| + | `enrollment-prompt` | 10 | + | `enrollment-user-write` | 20 | + | `enrollment-user-login` | 30 | + +### Step 4.7: Bind the Group to the Kaboot Application + +1. Go to **Applications** > **Applications** > **Kaboot** + +2. Go to the **Policy / Group / User Bindings** tab + +3. Click **Bind existing group** + +4. Select `kaboot-users` + +5. Click **Bind** + +Now users in the `kaboot-users` group (which includes all users who sign up) will have access to Kaboot. + +### Step 4.8: Link Enrollment Flow to Login + +1. Go to **Flows and Stages** > **Stages** + +2. Find and click on `default-authentication-identification` + +3. Scroll down to **Flow settings** + +4. In the **Enrollment flow** dropdown, select `enrollment-flow` + +5. Click **Update** + +Now when users visit the login page, they'll see a "Need an account? Sign up." link. + +### Optional: Add Password Recovery + +1. In **Flows and Stages** > **Stages** > `default-authentication-identification` + +2. Set **Recovery flow** to `default-recovery-flow` (if it exists) + +3. Click **Update** + +## Step 5: Verify OIDC Endpoints After creation, go to **Applications** > **Providers** > **Kaboot OAuth2** diff --git a/hooks/useAuthenticatedFetch.ts b/hooks/useAuthenticatedFetch.ts index c5b4e37..04438dd 100644 --- a/hooks/useAuthenticatedFetch.ts +++ b/hooks/useAuthenticatedFetch.ts @@ -6,35 +6,70 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; export const useAuthenticatedFetch = () => { const auth = useAuth(); + const isTokenExpired = useCallback(() => { + if (!auth.user?.expires_at) return true; + const expiresAt = auth.user.expires_at * 1000; + const now = Date.now(); + const bufferMs = 60 * 1000; + return now >= expiresAt - bufferMs; + }, [auth.user?.expires_at]); + + const ensureValidToken = useCallback(async (): Promise => { + if (!auth.user?.access_token) { + throw new Error('Not authenticated'); + } + + if (isTokenExpired()) { + try { + const user = await auth.signinSilent(); + if (user?.access_token) { + return user.access_token; + } + } catch { + auth.signinRedirect(); + throw new Error('Session expired, redirecting to login'); + } + } + + return auth.user.access_token; + }, [auth, isTokenExpired]); + const authFetch = useCallback( async (path: string, options: RequestInit = {}): Promise => { - if (!auth.user?.access_token) { - throw new Error('Not authenticated'); - } - + const token = await ensureValidToken(); const url = path.startsWith('http') ? path : `${API_URL}${path}`; const response = await fetch(url, { ...options, headers: { ...options.headers, - Authorization: `Bearer ${auth.user.access_token}`, + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, }); if (response.status === 401) { try { - await auth.signinSilent(); + const user = await auth.signinSilent(); + if (user?.access_token) { + return fetch(url, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${user.access_token}`, + 'Content-Type': 'application/json', + }, + }); + } } catch { auth.signinRedirect(); } - throw new Error('Token expired, please retry'); + throw new Error('Session expired, redirecting to login'); } return response; }, - [auth] + [auth, ensureValidToken] ); return { diff --git a/index.tsx b/index.tsx index d228d62..f872db1 100644 --- a/index.tsx +++ b/index.tsx @@ -9,10 +9,20 @@ if (!rootElement) { throw new Error("Could not find root element to mount to"); } +const onSigninCallback = () => { + window.history.replaceState({}, document.title, window.location.pathname); +}; + const root = ReactDOM.createRoot(rootElement); root.render( - + { + window.localStorage.clear(); + }} + > diff --git a/src/config/oidc.ts b/src/config/oidc.ts index e31341a..ba3ac5a 100644 --- a/src/config/oidc.ts +++ b/src/config/oidc.ts @@ -12,9 +12,8 @@ export const oidcConfig = { response_type: 'code', scope: 'openid profile email offline_access', automaticSilentRenew: true, + silentRequestTimeoutInSeconds: 10, loadUserInfo: true, userStore: new WebStorageStateStore({ store: window.localStorage }), - onSigninCallback: () => { - window.history.replaceState({}, document.title, window.location.pathname); - }, + monitorSession: false, }; diff --git a/vite.config.ts b/vite.config.ts index ee5fb8d..8738b76 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,7 +6,7 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, '.', ''); return { server: { - port: 3000, + port: 5173, host: '0.0.0.0', }, plugins: [react()], From 93ea01525e34960c9bf194e5124fabbc2fd91130 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 13 Jan 2026 16:38:25 -0700 Subject: [PATCH 06/10] Phase 5 complete --- App.tsx | 41 ++++-- IMPLEMENTATION_PLAN.md | 46 +++---- components/QuizCreator.tsx | 29 ++++- components/SaveQuizPrompt.tsx | 102 +++++++++++++++ hooks/useGame.ts | 12 +- server/src/routes/quizzes.ts | 2 +- server/tests/api.test.ts | 238 ++++++++++++++++++++++++++++++++++ 7 files changed, 433 insertions(+), 37 deletions(-) create mode 100644 components/SaveQuizPrompt.tsx diff --git a/App.tsx b/App.tsx index 52680ce..405be5d 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { useAuth } from 'react-oidc-context'; import { useGame } from './hooks/useGame'; +import { useQuizLibrary } from './hooks/useQuizLibrary'; import { Landing } from './components/Landing'; import { Lobby } from './components/Lobby'; import { GameScreen } from './components/GameScreen'; @@ -7,6 +9,7 @@ import { Scoreboard } from './components/Scoreboard'; import { Podium } from './components/Podium'; import { QuizCreator } from './components/QuizCreator'; import { RevealScreen } from './components/RevealScreen'; +import { SaveQuizPrompt } from './components/SaveQuizPrompt'; const seededRandom = (seed: number) => { const x = Math.sin(seed * 9999) * 10000; @@ -34,6 +37,8 @@ const FloatingShapes = React.memo(() => { }); function App() { + const auth = useAuth(); + const { saveQuiz } = useQuizLibrary(); const { role, gameState, @@ -57,9 +62,19 @@ function App() { selectedOption, currentPlayerScore, currentStreak, - currentPlayerId + currentPlayerId, + pendingQuizToSave, + dismissSavePrompt } = useGame(); + const handleSaveQuiz = async () => { + if (!pendingQuizToSave) return; + const source = pendingQuizToSave.topic ? 'ai_generated' : 'manual'; + const topic = pendingQuizToSave.topic || undefined; + await saveQuiz(pendingQuizToSave.quiz, source, topic); + dismissSavePrompt(); + }; + const currentQ = quiz?.questions[currentQuestionIndex]; // Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape) @@ -92,13 +107,23 @@ function App() { ) : null} {gameState === 'LOBBY' ? ( - + <> + + {auth.isAuthenticated && pendingQuizToSave && ( + + )} + ) : null} {(gameState === 'COUNTDOWN' || gameState === 'QUESTION') && quiz ? ( diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 484decc..cc3656e 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -236,31 +236,31 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U ## Phase 5: Save Integration ### 5.1 Save After AI Generation -- [ ] Modify `src/hooks/useGame.ts`: - - [ ] Add `pendingQuizToSave` state - - [ ] After successful AI generation, set `pendingQuizToSave` - - [ ] Add `savePendingQuiz()` and `dismissSavePrompt()` functions - - [ ] Export these for UI to consume -- [ ] Create `src/components/SaveQuizPrompt.tsx`: - - [ ] Modal asking "Save this quiz to your library?" - - [ ] Show quiz title - - [ ] "Save" and "Skip" buttons - - [ ] Only show when authenticated +- [x] Modify `hooks/useGame.ts`: + - [x] Add `pendingQuizToSave` state + - [x] After successful AI generation, set `pendingQuizToSave` + - [x] Add `dismissSavePrompt()` function + - [x] Export these for UI to consume +- [x] Create `components/SaveQuizPrompt.tsx`: + - [x] Modal asking "Save this quiz to your library?" + - [x] Show quiz title + - [x] "Save" and "Skip" buttons + - [x] Loading state while saving +- [x] Wire up in `App.tsx`: + - [x] Show SaveQuizPrompt in LOBBY state when authenticated and pendingQuizToSave exists + - [x] Handle save via useQuizLibrary hook ### 5.2 Save in Quiz Creator -- [ ] Modify `src/components/QuizCreator.tsx`: - - [ ] Add checkbox or toggle: "Save to my library" - - [ ] Pass `shouldSave` flag to `onFinalize` -- [ ] Modify `src/hooks/useGame.ts`: - - [ ] Update `finalizeManualQuiz` to accept save preference - - [ ] If save requested + authenticated, call `saveQuiz` +- [x] Modify `components/QuizCreator.tsx`: + - [x] Add "Save to my library" checkbox (only shown when authenticated) + - [x] Pass `saveToLibrary` flag to `onFinalize` +- [x] Modify `hooks/useGame.ts`: + - [x] Update `finalizeManualQuiz` to accept save preference + - [x] If save requested, set `pendingQuizToSave` -### 5.3 Load Quiz Flow -- [ ] Modify `src/hooks/useGame.ts`: - - [ ] Add `loadSavedQuiz(quiz: Quiz)` function - - [ ] Initialize game state with loaded quiz - - [ ] Transition to LOBBY state -- [ ] Wire up from Landing → QuizLibrary → useGame +### 5.3 Load Quiz Flow (Already done in Phase 4) +- [x] `loadSavedQuiz(quiz: Quiz)` function in useGame.ts +- [x] Wire up from Landing → QuizLibrary → useGame --- @@ -407,6 +407,6 @@ kaboot/ | Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD | | Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch | | Phase 4 | **COMPLETE** | useQuizLibrary hook, QuizLibrary modal, Landing integration | -| Phase 5 | Not Started | | +| Phase 5 | **COMPLETE** | SaveQuizPrompt modal, QuizCreator save checkbox, save integration | | Phase 6 | Not Started | | | Phase 7 | Not Started | | diff --git a/components/QuizCreator.tsx b/components/QuizCreator.tsx index 726e527..7260cd1 100644 --- a/components/QuizCreator.tsx +++ b/components/QuizCreator.tsx @@ -1,21 +1,24 @@ import React, { useState } from 'react'; +import { useAuth } from 'react-oidc-context'; import { Quiz, Question, AnswerOption } from '../types'; import { v4 as uuidv4 } from 'uuid'; -import { Plus, Save, Trash2, CheckCircle, Circle, X } from 'lucide-react'; +import { Plus, Save, Trash2, CheckCircle, Circle, X, BookOpen } from 'lucide-react'; import { COLORS, SHAPES } from '../constants'; interface QuizCreatorProps { - onFinalize: (quiz: Quiz) => void; + onFinalize: (quiz: Quiz, saveToLibrary: boolean) => void; onCancel: () => void; } export const QuizCreator: React.FC = ({ onFinalize, onCancel }) => { + const auth = useAuth(); const [title, setTitle] = useState(''); const [questions, setQuestions] = useState([]); const [qText, setQText] = useState(''); const [options, setOptions] = useState(['', '', '', '']); const [reasons, setReasons] = useState(['', '', '', '']); const [correctIdx, setCorrectIdx] = useState(0); + const [saveToLibrary, setSaveToLibrary] = useState(false); const handleAddQuestion = () => { if (!qText.trim() || options.some(o => !o.trim())) { @@ -53,7 +56,7 @@ export const QuizCreator: React.FC = ({ onFinalize, onCancel } const handleFinalize = () => { if (!title.trim() || questions.length === 0) return; - onFinalize({ title, questions }); + onFinalize({ title, questions }, saveToLibrary); }; return ( @@ -176,7 +179,25 @@ export const QuizCreator: React.FC = ({ onFinalize, onCancel }
-
+
+ {auth.isAuthenticated ? ( + + ) : ( +
+ )} + + +
+
+ +
+ +
+ + + + )} + + ); +}; diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 63df37e..966e64f 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -21,6 +21,7 @@ export const useGame = () => { const [currentStreak, setCurrentStreak] = useState(0); const [currentPlayerId, setCurrentPlayerId] = useState(null); const [currentPlayerName, setCurrentPlayerName] = useState(null); + const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null); const timerRef = useRef | null>(null); const peerRef = useRef(null); @@ -48,6 +49,7 @@ export const useGame = () => { setError(null); setRole('HOST'); const generatedQuiz = await generateQuiz(topic); + setPendingQuizToSave({ quiz: generatedQuiz, topic }); initializeHostGame(generatedQuiz); } catch (e) { setError("Failed to generate quiz."); @@ -55,12 +57,19 @@ export const useGame = () => { } }; + const dismissSavePrompt = () => { + setPendingQuizToSave(null); + }; + const startManualCreation = () => { setRole('HOST'); setGameState('CREATING'); }; - const finalizeManualQuiz = (manualQuiz: Quiz) => { + const finalizeManualQuiz = (manualQuiz: Quiz, saveToLibrary: boolean = false) => { + if (saveToLibrary) { + setPendingQuizToSave({ quiz: manualQuiz, topic: '' }); + } initializeHostGame(manualQuiz); }; @@ -383,6 +392,7 @@ export const useGame = () => { return { role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, + pendingQuizToSave, dismissSavePrompt, startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion }; }; \ No newline at end of file diff --git a/server/src/routes/quizzes.ts b/server/src/routes/quizzes.ts index ed4c06d..c6fa916 100644 --- a/server/src/routes/quizzes.ts +++ b/server/src/routes/quizzes.ts @@ -88,7 +88,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => { const body = req.body as QuizBody; const { title, source, aiTopic, questions } = body; - if (!title || !source || !questions?.length) { + if (!title?.trim() || !source || !questions?.length) { res.status(400).json({ error: 'Missing required fields: title, source, questions' }); return; } diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index 69d9d79..09454e4 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -426,6 +426,244 @@ async function runTests() { await request('DELETE', `/api/quizzes/${timestampQuizId}`, undefined, 204); }); + console.log('\nSave Integration Tests (Phase 5):'); + let manualSaveQuizId: string | null = null; + let aiSaveQuizId: string | null = null; + + await test('POST /api/quizzes manual quiz without aiTopic', async () => { + const manualQuiz = { + title: 'Manual Save Test Quiz', + source: 'manual', + questions: [ + { + text: 'What is 1+1?', + timeLimit: 20, + options: [ + { text: '1', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: '2', isCorrect: true, shape: 'diamond', color: 'blue' }, + { text: '3', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: '4', isCorrect: false, shape: 'square', color: 'green' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', manualQuiz, 201); + const result = data as { id: string }; + manualSaveQuizId = result.id; + }); + + await test('GET manual quiz has null aiTopic', async () => { + if (!manualSaveQuizId) throw new Error('No manual quiz created'); + const { data } = await request('GET', `/api/quizzes/${manualSaveQuizId}`); + const quiz = data as Record; + if (quiz.source !== 'manual') throw new Error('Wrong source'); + if (quiz.aiTopic !== null) throw new Error(`Expected null aiTopic, got ${quiz.aiTopic}`); + }); + + await test('POST /api/quizzes ai_generated with empty aiTopic treated as null', async () => { + const aiQuiz = { + title: 'AI Quiz Empty Topic', + source: 'ai_generated', + aiTopic: '', + questions: [ + { + text: 'Test?', + options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', aiQuiz, 201); + aiSaveQuizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${aiSaveQuizId}`); + const quiz = getResult as Record; + if (quiz.aiTopic !== null && quiz.aiTopic !== '') { + throw new Error(`Expected null/empty aiTopic for empty string, got ${quiz.aiTopic}`); + } + }); + + await test('DELETE cleanup manual save quiz', async () => { + if (manualSaveQuizId) { + await request('DELETE', `/api/quizzes/${manualSaveQuizId}`, undefined, 204); + } + }); + + await test('DELETE cleanup ai save quiz', async () => { + if (aiSaveQuizId) { + await request('DELETE', `/api/quizzes/${aiSaveQuizId}`, undefined, 204); + } + }); + + console.log('\nOption Preservation Tests:'); + let optionQuizId: string | null = null; + + await test('POST /api/quizzes preserves all option fields including reason', async () => { + const quizWithReasons = { + title: 'Quiz With Reasons', + source: 'manual', + questions: [ + { + text: 'Capital of France?', + timeLimit: 15, + options: [ + { text: 'London', isCorrect: false, shape: 'triangle', color: 'red', reason: 'London is the capital of UK' }, + { text: 'Paris', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct! Paris is the capital of France' }, + { text: 'Berlin', isCorrect: false, shape: 'circle', color: 'yellow', reason: 'Berlin is the capital of Germany' }, + { text: 'Madrid', isCorrect: false, shape: 'square', color: 'green', reason: 'Madrid is the capital of Spain' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', quizWithReasons, 201); + optionQuizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${optionQuizId}`); + const quiz = getResult as { questions: { options: { text: string; isCorrect: boolean; shape: string; color: string; reason?: string }[] }[] }; + + const options = quiz.questions[0].options; + if (options.length !== 4) throw new Error('Expected 4 options'); + + const parisOpt = options.find(o => o.text === 'Paris'); + if (!parisOpt) throw new Error('Paris option not found'); + if (!parisOpt.isCorrect) throw new Error('Paris should be correct'); + if (parisOpt.reason !== 'Correct! Paris is the capital of France') throw new Error('Paris reason not preserved'); + + const londonOpt = options.find(o => o.text === 'London'); + if (!londonOpt) throw new Error('London option not found'); + if (londonOpt.isCorrect) throw new Error('London should not be correct'); + if (londonOpt.reason !== 'London is the capital of UK') throw new Error('London reason not preserved'); + }); + + await test('POST /api/quizzes options without reason field are preserved', async () => { + const quizNoReasons = { + title: 'Quiz Without Reasons', + source: 'ai_generated', + aiTopic: 'Geography', + questions: [ + { + text: 'Largest ocean?', + options: [ + { text: 'Atlantic', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: 'Pacific', isCorrect: true, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', quizNoReasons, 201); + const quizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); + const quiz = getResult as { questions: { options: { reason?: string }[] }[] }; + + const options = quiz.questions[0].options; + const pacificOpt = options.find((o: any) => o.text === 'Pacific'); + if (pacificOpt?.reason !== null && pacificOpt?.reason !== undefined) { + throw new Error('Expected null/undefined reason for option without reason'); + } + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + await test('DELETE cleanup option quiz', async () => { + if (optionQuizId) { + await request('DELETE', `/api/quizzes/${optionQuizId}`, undefined, 204); + } + }); + + console.log('\nConcurrent Save Tests:'); + + await test('Multiple quizzes can be saved by same user', async () => { + const quiz1 = { + title: 'Concurrent Quiz 1', + source: 'manual', + questions: [{ text: 'Q1?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }], + }; + const quiz2 = { + title: 'Concurrent Quiz 2', + source: 'ai_generated', + aiTopic: 'Science', + questions: [{ text: 'Q2?', options: [{ text: 'B', isCorrect: true, shape: 'diamond', color: 'blue' }] }], + }; + + const [res1, res2] = await Promise.all([ + request('POST', '/api/quizzes', quiz1, 201), + request('POST', '/api/quizzes', quiz2, 201), + ]); + + const id1 = (res1.data as { id: string }).id; + const id2 = (res2.data as { id: string }).id; + + if (id1 === id2) throw new Error('Quiz IDs should be unique'); + + const { data: listData } = await request('GET', '/api/quizzes'); + const list = listData as { id: string; title: string }[]; + + const found1 = list.find(q => q.id === id1); + const found2 = list.find(q => q.id === id2); + + if (!found1) throw new Error('Quiz 1 not in list'); + if (!found2) throw new Error('Quiz 2 not in list'); + + await Promise.all([ + request('DELETE', `/api/quizzes/${id1}`, undefined, 204), + request('DELETE', `/api/quizzes/${id2}`, undefined, 204), + ]); + }); + + console.log('\nEdge Case Tests:'); + + await test('POST /api/quizzes with very long title', async () => { + const longTitle = 'A'.repeat(500); + const quiz = { + title: longTitle, + source: 'manual', + questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }], + }; + + const { data } = await request('POST', '/api/quizzes', quiz, 201); + const quizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); + if ((getResult as { title: string }).title !== longTitle) { + throw new Error('Long title not preserved'); + } + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + await test('POST /api/quizzes with special characters in title', async () => { + const specialTitle = 'Quiz with "quotes" & and emoji test'; + const quiz = { + title: specialTitle, + source: 'manual', + questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }], + }; + + const { data } = await request('POST', '/api/quizzes', quiz, 201); + const quizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); + if ((getResult as { title: string }).title !== specialTitle) { + throw new Error('Special characters not preserved'); + } + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + await test('POST /api/quizzes with whitespace-only title returns 400', async () => { + const quiz = { + title: ' ', + source: 'manual', + questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }], + }; + + await request('POST', '/api/quizzes', quiz, 400); + }); + console.log('\n=== Results ==='); const passed = results.filter((r) => r.passed).length; const failed = results.filter((r) => !r.passed).length; From 3a22b4249210209e963cc2bee15a88ea1b02412a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 13 Jan 2026 16:52:57 -0700 Subject: [PATCH 07/10] Phase 6 complete --- IMPLEMENTATION_PLAN.md | 42 ++-- components/Landing.tsx | 15 +- components/QuizLibrary.tsx | 58 ++++-- hooks/useAuthenticatedFetch.ts | 32 ++- hooks/useQuizLibrary.ts | 189 ++++++++++++++---- index.tsx | 26 +++ package-lock.json | 34 ++++ package.json | 17 +- server/src/index.ts | 15 +- server/src/routes/quizzes.ts | 64 +++++- server/tests/api.test.ts | 346 ++++++++++++++++++++++++++++++++- 11 files changed, 735 insertions(+), 103 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index cc3656e..2a80a42 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -267,32 +267,32 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U ## Phase 6: Polish & Error Handling ### 6.1 Loading States -- [ ] Add loading indicators: - - [ ] Quiz library list loading - - [ ] Quiz loading when selected - - [ ] Save operation in progress -- [ ] Disable buttons during async operations +- [x] Add loading indicators: + - [x] Quiz library list loading + - [x] Quiz loading when selected + - [x] Save operation in progress +- [x] Disable buttons during async operations ### 6.2 Error Handling -- [ ] Display user-friendly error messages: - - [ ] Failed to load quiz library - - [ ] Failed to save quiz - - [ ] Failed to delete quiz - - [ ] Network/auth errors -- [ ] Add retry mechanisms where appropriate +- [x] Display user-friendly error messages: + - [x] Failed to load quiz library + - [x] Failed to save quiz + - [x] Failed to delete quiz + - [x] Network/auth errors +- [x] Add retry mechanisms where appropriate ### 6.3 Toast Notifications (Optional) -- [ ] Add `react-hot-toast` or similar -- [ ] Show success toasts: - - [ ] "Quiz saved successfully" - - [ ] "Quiz deleted" -- [ ] Show error toasts for failures +- [x] Add `react-hot-toast` or similar +- [x] Show success toasts: + - [x] "Quiz saved successfully" + - [x] "Quiz deleted" +- [x] Show error toasts for failures ### 6.4 Edge Cases -- [ ] Handle auth token expiry gracefully -- [ ] Handle offline state -- [ ] Handle concurrent save attempts -- [ ] Validate quiz data before save +- [x] Handle auth token expiry gracefully +- [x] Handle offline state +- [x] Handle concurrent save attempts +- [x] Validate quiz data before save --- @@ -408,5 +408,5 @@ kaboot/ | Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch | | Phase 4 | **COMPLETE** | useQuizLibrary hook, QuizLibrary modal, Landing integration | | Phase 5 | **COMPLETE** | SaveQuizPrompt modal, QuizCreator save checkbox, save integration | -| Phase 6 | Not Started | | +| Phase 6 | **COMPLETE** | Toast notifications, loading states, error handling, edge cases | | Phase 7 | Not Started | | diff --git a/components/Landing.tsx b/components/Landing.tsx index 6b90774..8e69678 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -24,7 +24,17 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const [name, setName] = useState(''); const [libraryOpen, setLibraryOpen] = useState(false); - const { quizzes, loading: libraryLoading, error: libraryError, fetchQuizzes, loadQuiz, deleteQuiz } = useQuizLibrary(); + const { + quizzes, + loading: libraryLoading, + loadingQuizId, + deletingQuizId, + error: libraryError, + fetchQuizzes, + loadQuiz, + deleteQuiz, + retry: retryLibrary + } = useQuizLibrary(); useEffect(() => { if (libraryOpen && auth.isAuthenticated) { @@ -169,9 +179,12 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on onClose={() => setLibraryOpen(false)} quizzes={quizzes} loading={libraryLoading} + loadingQuizId={loadingQuizId} + deletingQuizId={deletingQuizId} error={libraryError} onLoadQuiz={handleLoadQuiz} onDeleteQuiz={deleteQuiz} + onRetry={retryLibrary} />
); diff --git a/components/QuizLibrary.tsx b/components/QuizLibrary.tsx index 9a0e4b7..ba2ce3c 100644 --- a/components/QuizLibrary.tsx +++ b/components/QuizLibrary.tsx @@ -8,9 +8,12 @@ interface QuizLibraryProps { onClose: () => void; quizzes: QuizListItem[]; loading: boolean; + loadingQuizId: string | null; + deletingQuizId: string | null; error: string | null; onLoadQuiz: (id: string) => void; onDeleteQuiz: (id: string) => void; + onRetry: () => void; } export const QuizLibrary: React.FC = ({ @@ -18,28 +21,36 @@ export const QuizLibrary: React.FC = ({ onClose, quizzes, loading, + loadingQuizId, + deletingQuizId, error, onLoadQuiz, onDeleteQuiz, + onRetry, }) => { - const [deletingId, setDeletingId] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId; const handleDeleteClick = (e: React.MouseEvent, id: string) => { e.stopPropagation(); - setDeletingId(id); + setConfirmDeleteId(id); }; - const confirmDelete = (e: React.MouseEvent) => { + const confirmDelete = async (e: React.MouseEvent) => { e.stopPropagation(); - if (deletingId) { - onDeleteQuiz(deletingId); - setDeletingId(null); + if (confirmDeleteId) { + try { + await onDeleteQuiz(confirmDeleteId); + setConfirmDeleteId(null); + } catch { + setConfirmDeleteId(null); + } } }; const cancelDelete = (e: React.MouseEvent) => { e.stopPropagation(); - setDeletingId(null); + setConfirmDeleteId(null); }; const formatDate = (dateString: string) => { @@ -95,8 +106,14 @@ export const QuizLibrary: React.FC = ({ )} {!loading && error && ( -
- {error} +
+

{error}

+
)} @@ -116,8 +133,8 @@ export const QuizLibrary: React.FC = ({ layout initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} - className="group bg-white p-4 rounded-2xl border-2 border-gray-100 hover:border-theme-primary hover:shadow-md transition-all cursor-pointer relative overflow-hidden" - onClick={() => onLoadQuiz(quiz.id)} + className={`group bg-white p-4 rounded-2xl border-2 border-gray-100 hover:border-theme-primary hover:shadow-md transition-all relative overflow-hidden ${isAnyOperationInProgress ? 'cursor-not-allowed opacity-70' : 'cursor-pointer'}`} + onClick={() => !isAnyOperationInProgress && onLoadQuiz(quiz.id)} >
@@ -147,17 +164,27 @@ export const QuizLibrary: React.FC = ({
- {deletingId === quiz.id ? ( + {loadingQuizId === quiz.id ? ( +
+ +
+ ) : deletingQuizId === quiz.id ? ( +
+ +
+ ) : confirmDeleteId === quiz.id ? (
e.stopPropagation()}> @@ -166,7 +193,8 @@ export const QuizLibrary: React.FC = ({ <>