Dockerized React + TypeScript admin portal for a Cyber Security Lab. Neon-terminal aesthetic, JWT-protected login backed by a Node/Express API.
- Frontend: Vite + React 18 + TypeScript + Tailwind CSS v4 + React Router
- Backend: Node.js 22 + Express + jsonwebtoken + helmet + express-rate-limit
- Infra: Docker Compose, Nginx (serves SPA + proxies
/api→ backend)
cp .env.example .env # optional — defaults work for local
docker compose up --buildOpen http://localhost:8080. Login with:
username: admin
password: admin
The Exam-Portal/ folder is shipped as a 4th compose service exam-portal (host port 3000). It runs its own Postgres + Nginx + Docker-in-Docker inside one container.
Single sign-on: The main backend and exam-portal share JWT_SECRET. When a logged-in user clicks an Exam Portal link from /admin or /student, the main frontend redirects to http://<host>:3000/sso?token=<jwt>&next=/admin (or /). The Sso page stores the token in localStorage and loads the app. On the first authenticated API call, exam-portal upserts the user (by username + role) into its own Postgres users table.
Admin control: /admin shows an "Exam Portal" card with:
- Live summary (exam count, in-progress attempts, active labs) via
GET /api/exam-portal/summary(proxied to the exam-portal container). - An "Enabled for students" toggle persisted to
${DATA_DIR}/exam_portal_settings.json. When off, the Exam Portal tile is hidden from/student. - An "Open Exam Portal Admin" button that opens
/adminin exam-portal already logged in.
Required shared env: JWT_SECRET must be identical for backend and exam-portal. EXAM_PORTAL_PUBLIC_URL (default http://lab.upskillnexus.local:3000) is the URL the browser is sent to; EXAM_PORTAL_INTERNAL_URL (default http://exam-portal:4000) is used server-to-server for the summary widget.
POST /api/auth/login—{username, password}→{token, user}GET /api/auth/me—Authorization: Bearer <token>→{user}
frontend/ React SPA, multi-stage Docker → nginx:alpine
backend/ Express API, node:22-alpine
data/ Persisted curriculum + lab source (committed to git)
The backend persists everything to ./data/ on the host (bind-mounted to /data in the container). That directory is part of the repo, so a normal git push / git pull carries your modules and uploaded lab source between machines.
What's committed:
data/modules.json— modules, subtopics, lab metadata (image tag, source dir, exposed port, etc.)data/labs/<labId>/...— extracted lab source (Dockerfile + files) from each admin upload
What's NOT committed (per .gitignore):
data/sessions.json— per-machine runtime state (tokens, container IDs)data/*.tmp— atomic-write temp files
Built docker images are not committed. On the first docker compose up after a pull, the backend automatically rebuilds any lab image that isn't already loaded — watch docker logs -f lab-backend for [reconcile] rebuilding image ... → [reconcile] ready .... While a build is in progress the admin panel shows the lab as "Building…".
If a lab's source dir is missing on the new machine, that lab is marked "Build failed: lab source missing — re-upload zip" and the rest of the system stays healthy.
If you previously ran the stack with a Docker named volume, copy its contents into ./data/ before the first commit:
docker compose down
docker run --rm -v lab-data:/from -v "$PWD/data":/to alpine sh -c 'cp -a /from/. /to/'
git add data/
git commit -m "seed curriculum data"
docker compose up -dSwitch data/labs/** to Git LFS to keep clones fast.