No description
  • TypeScript 97.5%
  • JavaScript 1.7%
  • Dockerfile 0.5%
  • Shell 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
alexxasO dd7612fd18
Some checks were skipped
Build and Push Docker Image / build-and-push (./, backend/Dockerfile, backend) (push) Has been skipped
Build and Push Docker Image / build-and-push (./frontend, frontend/Dockerfile, frontend) (push) Has been skipped
feat: update PROMO_MAPPING to include new student promotion codes for various programs
2026-09-02 10:26:35 +02:00
.github/workflows fix: remove 'dev' and 'develop' branches from workflow triggers 2026-08-21 16:44:46 +02:00
backend feat: update PROMO_MAPPING to include new student promotion codes for various programs 2026-09-02 10:26:35 +02:00
frontend fix: replace native time input with bounded numeric inputs for session generation time picker to ensure consistent 24-hour display 2026-08-31 11:23:08 +02:00
nfc-agent feat(env) : switch environnement to runtime instead of build stage 2026-08-21 11:34:58 +02:00
pdf docs: add non technical documentation 2026-08-31 16:00:02 +02:00
.dockerignore fix: correct Docker build context and embed nfc-agent for agent-download functionality 2026-08-21 09:45:46 +02:00
.env.example feat(env) : switch environnement to runtime instead of build stage 2026-08-21 11:34:58 +02:00
.gitignore feat: update PROMO_MAPPING to include 'Pédagogie' for Peda 2026-08-21 16:42:29 +02:00
CHANGELOG.md fix: replace native time input with bounded numeric inputs for session generation time picker to ensure consistent 24-hour display 2026-08-31 11:23:08 +02:00
docker-compose.prod.yml feat(env) : switch environnement to runtime instead of build stage 2026-08-21 11:34:58 +02:00
docker-compose.yml fix(frontend): change environment variables to build args in docker-compose 2026-08-21 12:28:27 +02:00
README.md feat: update student import process to make uid optional and enhance identity handling 2026-08-24 11:40:28 +02:00
ROADMAP.md feat: add alumni flag to Student model and implement promoteAll functionality 2026-08-24 13:23:23 +02:00

Epitech Badger v2

Attendance tracking platform for Epitech students — NFC + QR Code badging, Microsoft authentication, session supervision, and a staff visualization dashboard.

This is a from-scratch rewrite (see ROADMAP.md / CHANGELOG.md) of the original Epitech-Badger project. Only the NFC concept (ACR122U reader, UID-based badging) was carried over; everything else has been redesigned on a new stack.

Stack

Layer Choice
Backend NestJS + TypeScript
ORM / migrations Prisma
Database PostgreSQL
Frontend React + TypeScript + Vite
Design system @epitech/tools-react-components (Mantine-based)
Auth Microsoft OAuth2 (MSAL), app-wide

Project layout

backend/    NestJS API (students, attendance, sessions, auth, sync-my, nfc)
frontend/   React app (Vite)
nfc-agent/  Standalone local agent driving a physical NFC reader (see its own README)
docker-compose.yml
.env.example    Root env file for docker-compose.yml (distinct from backend/frontend's own)
ROADMAP.md
CHANGELOG.md

Getting started (local development)

1. Backend

cd backend
npm install
cp .env.example .env   # fill in MS_TENANT_ID / MS_CLIENT_ID / MS_CLIENT_SECRET, etc.
npx prisma generate
npx prisma migrate dev
npm run start:dev

The API listens on http://localhost:3001, with Swagger docs at /api-docs.

2. Frontend

cd frontend
npm install
cp .env.example .env.local   # fill in VITE_MS_CLIENT_ID / VITE_MS_TENANT_ID
npm run dev

The app runs on http://localhost:5173 (Vite default) or as configured.

3. Full stack via Docker Compose

Copy the root .env.example to .env (same folder as docker-compose.yml) and fill it in — Compose reads it automatically:

cp .env.example .env   # fill in MS_TENANT_ID, MS_CLIENT_ID, MS_CLIENT_SECRET,
                        # APP_JWT_SECRET, QR_TOTP_SECRET, VITE_API_BASE_URL, etc. —
                        # see the file's own comments for what each variable needs
docker compose up -d --build

A few things worth knowing before you do:

  • Frontend configuration is read at container start. The production Compose file loads VITE_MS_CLIENT_ID, VITE_MS_TENANT_ID, and VITE_API_BASE_URL from .env.frontend and writes them to the frontend runtime configuration. Create that file next to docker-compose.prod.yml; changing it only requires recreating the frontend container, not rebuilding the image:
    VITE_MS_CLIENT_ID=your-application-client-id
    VITE_MS_TENANT_ID=your-tenant-id
    VITE_API_BASE_URL=https://badger.example.com/api
    
  • Set VITE_API_BASE_URL to the URL a real user's browser will use to reach the backend — not localhost, unless you're only ever accessing this deployment from the same machine the containers run on. Get this wrong and every API call from an actual visitor's browser silently fails.
  • Deploying anywhere beyond localhost needs HTTPS, or Microsoft login will fail outright. MSAL relies on the browser's Web Crypto API (window.crypto.subtle), which browsers only expose in a "secure context" — https:// origins, or http://localhost. A plain http:// deployment on a LAN IP or a real domain throws BrowserAuthError: crypto_nonexistent and the app never even mounts (a blank page, no other error). Put a reverse proxy with TLS in front of both services for anything beyond local development (Caddy is the simplest to set up; Traefik, nginx+certbot, or an existing gateway like Pangolin work too). Once that's in place, also update VITE_API_BASE_URL to the HTTPS URL and add it as a redirect URI on the Azure AD App Registration, or Microsoft login will reject it with AADSTS500113.
  • Database migrations are applied automatically on container start — no manual docker compose exec backend npx prisma migrate deploy needed after a deploy. backend/docker-entrypoint.sh runs prisma migrate deploy first and only starts the app if that succeeds; if migrations fail, the container exits non-zero and restarts instead of quietly serving the app against a stale schema. This only applies to the container — the local dev workflow above (npx prisma migrate dev, outside Docker) is unchanged and still manual.
  • Postgres's port is not exposed to the host by default (only reachable from backend over the internal Docker network) — uncomment the relevant line in docker-compose.yml, bound to 127.0.0.1, if you need direct DB access from the host for debugging.
  • backend exposes GET /health, used by its own Docker healthcheck; frontend only starts once backend reports healthy.

Dev login bypass (local development only)

POST /auth/dev-login mints an application JWT for a seeded test account (student or dev staff) with no Microsoft login at all, exposed on the frontend as /dev-login — a page of large, tappable buttons meant for quickly testing any role, especially the student flow on an actual phone (see below) without doing a real Microsoft login on it every time.

This must never be reachable outside a local machine. It's protected by two conditions that both have to hold:

  1. Frontend: /dev-login is only registered as a route in Vite dev builds (import.meta.env.DEV, see frontend/src/App.tsx) — a production build (npm run build) doesn't ship the route or the page at all.
  2. Backend: POST /auth/dev-login checks DEV_AUTH_BYPASS_ENABLED and returns 403 unless it is the exact string "true". It defaults to unset/false in .env.example — you have to opt in locally.

Never set DEV_AUTH_BYPASS_ENABLED=true in any staging or production environment. Doing so lets anyone who knows a seeded student's email sign in as that student with zero credentials. Both conditions have to be independently satisfied on purpose — a stray true in a shared .env alone isn't enough to expose anything, since a production frontend build never even offers the route.

Testing on a real phone (mobile student flow)

The student check-in flow (/badge, QR scanning, 8-digit code entry) is mobile-first and worth testing on an actual phone rather than just a resized browser window:

  1. Find your machine's local network IP (e.g. ip addr / ifconfig on Linux/macOS, ipconfig on Windows — something like 192.168.1.42).
  2. Start the frontend exposed on the network instead of just localhost:
    cd frontend
    npm run dev -- --host
    
  3. Point the frontend at the backend via that same IP instead of localhost — edit frontend/.env.local:
    VITE_API_BASE_URL=http://192.168.1.42:3001
    
    (restart npm run dev after changing it — Vite only reads .env.local on startup)
  4. Make sure the backend's CORS (app.enableCors() in backend/src/main.ts) and firewall allow that — the default enableCors() with no origin restriction already does.
  5. On your phone, connected to the same Wi-Fi network as your machine, open http://192.168.1.42:5173 (Vite's dev server, also needs --host to be reachable from another device) and, for a quick role switch without logging in via Microsoft on the phone, http://192.168.1.42:5173/dev-login.

Note the same secure-context caveat as above: a plain http:// LAN address is enough to hit this dev server directly (no MSAL login needed for /dev-login itself), but the real Microsoft login flow (/, redirecting through MSAL) will fail on it for the same crypto_nonexistent reason — /dev-login is exactly the workaround for that while testing locally over plain HTTP.

The @epitech/tools-react-components design system

Download it from the package's repository releases page (not from npm — it isn't published there). Version currently used in this project: 5.0.3.

Current state of this scaffold: the release archive can't be reached from the environment this scaffold was generated in, so the package's source (v5.0.3) has been vendored under frontend/src/vendor/tools-react-components instead, so that auth, layout, and loader components can be used right away. Two sub-components were dropped from the vendored copy because they depend on another private package (@epitech/tools-mantine-react-table): datatable and markdown. These aren't needed until Phase 8/9 (visualization dashboard, bulk overrides) — once you have the real release downloaded, replace the vendored folder with it and re-enable those two.

NFC architecture

The backend never talks to the physical reader itself. In production the ACR122U reader is plugged into a staff member's own machine (ad hoc), while the backend runs on a remote server — the local agent in nfc-agent/ (a separate, standalone Node project, see nfc-agent/README.md) drives the reader and posts each scan to the backend over HTTP. This keeps backend/package.json free of any hardware dependency (nfc-pcsc, pcscd, etc. — those only matter to nfc-agent/, not to the backend).

The flow:

  1. A staff/admin, authenticated with a normal application JWT, calls POST /nfc/station-token with a sessionId. This mints a short-lived, scope-limited station token (NFC_STATION_TOKEN_EXPIRES_IN, default 4h) that only grants access to POST /nfc/scan, and only for that one session — this is what makes multiple reader posts (one per room/promo) work safely without a shared "currently active session" concept.
  2. The reader post — nfc-agent/ running on the staff member's machine (or you, testing with curl) — calls POST /nfc/scan with { "uid": "<card uid>" }, authenticated with the station token — not a normal JWT. The sessionId is never in the request body, it comes from the station token itself.
  3. The backend matches the UID to a Student, records attendance for the session carried by the token (reusing the same logic as QR check-in), and broadcasts the result over the existing nfc WebSocket namespace for the staff display.

Testing the scan flow with curl

No physical reader is needed to exercise this end-to-end — get a staff JWT (see "Dev login bypass" above), mint a station token, then simulate a scan:

STAFF_JWT=$(curl -s -X POST http://localhost:3001/auth/dev-login \
  -H 'Content-Type: application/json' \
  -d '{"role":"STAFF","email":"some-staff@epitech.eu"}' | jq -r .accessToken)

STATION_JWT=$(curl -s -X POST http://localhost:3001/nfc/station-token \
  -H "Authorization: Bearer $STAFF_JWT" -H 'Content-Type: application/json' \
  -d '{"sessionId":"<a real PromoSession id>"}' | jq -r .accessToken)

# Known UID -> attendance recorded, broadcast over the nfc WebSocket namespace
curl -s -X POST http://localhost:3001/nfc/scan \
  -H "Authorization: Bearer $STATION_JWT" -H 'Content-Type: application/json' \
  -d '{"uid":"<a student'"'"'s nfcUid>"}'

# Unknown UID -> { "status": "UNKNOWN_CARD" }
curl -s -X POST http://localhost:3001/nfc/scan \
  -H "Authorization: Bearer $STATION_JWT" -H 'Content-Type: application/json' \
  -d '{"uid":"DEADBEEF"}'

A student's nfcUid can be set with PATCH /students/:id/nfc-uid.

Student directory: JSON import (active) vs My sync (dormant)

Two mechanisms exist for keeping the student roster up to date. Only one of them is actually usable right now.

backend/scripts/import-students-from-json.ts — the active mechanism. Standing in for My until a correctly-scoped API token is available. Takes a JSON file ({ "students": [{ "uid"?, "id"?, "name", "promo", "email"? }] }), maps promo through a strict, explicit table (unrecognized values are skipped with a warning, never guessed), and looks up an already-imported student before ever considering a row "new".

uid (the student's physical NFC card) is no longer required — it's useful to be able to seed a student before they've been issued a card at all. At least one of uid, id, or email must be present on each row, though — a row with none of them is skipped with a warning rather than silently dropped, since there'd be no way to recognize "the same student" on a later re-run otherwise.

An already-imported student is looked up by nfcUid first (if uid is given), then by email — either the real email this row provides, or the placeholder it would produce (<uid or id, lowercased>@imported.invalid), checked as independent candidates rather than a single value. That last part matters because a row's available identifiers can change between re-runs: a student first seeded via id alone (no card yet, placeholder built from id) who later gets uid added to that same row, once a physical card is finally issued, is still recognized as the same person by their id-based placeholder and updated, not duplicated — even though this row's uid alone would resolve to a different placeholder that was never actually stored. nfcUid itself only ever moves from unset to set: a row without uid never clears one that was set in the meantime, and a row with a different uid than what's already on file never overwrites it either — whatever card is already linked always wins. To actually change which card a student is linked to, use PATCH /students/:id/nfc-uid (or the admin edit form) — see the section below.

externalId is never set by this import — it stays null. It's reserved exclusively for the real My identifier, written only by the sync-my/ module described below, once it eventually runs. A student imported through this mechanism has externalId: null for as long as My sync stays dormant — nullable in the schema for exactly this (String? @unique; Postgres treats multiple NULLs in a unique column as distinct, the same mechanism already used for nfcUid), so any number of such students coexist without conflict.

Meant to be re-run regularly as fresh exports arrive — every re-run refreshes firstName/lastName/promo/email (the last only when the row actually provides one), but never touches active (deactivation is a dedicated admin action, never a side effect of a partial re-import). Run it with:

npx ts-node backend/scripts/import-students-from-json.ts path/to/students.json

See the file's own header comment, and student-import.ts's, for the full field-by-field reasoning.

POST /sync-my/students — dormant, ready when My is. Would trigger a sync against my.epitech.eu/api/students (STAFF/ADMIN, manual, never scheduled). The code (endpoint, service, DTO, cursus/tekYear promo mapping) is complete and untouched, but was never executed against the real API during this scaffold's build — confirm the exact field names in MyStudentDto (backend/src/sync-my/dto/my-student.dto.ts) against the real API response before relying on it. The day a working token with the right scope is obtained, this is the endpoint to switch to — no code changes needed, just start calling it instead of running the JSON import.

Associating an NFC card with a student (manual, for now)

There is currently no self-service flow for students to link their own NFC card. Until one exists, staff associate cards manually:

  1. Run npx ts-node backend/scripts/read-nfc-uid.ts with the reader plugged in, and tap the student's card. It prints the card's UID.
  2. Open npx prisma studio, find the student's row in the Student table, and paste the UID into the nfcUid column.

A proper in-app tool (a staff-facing "associate a card" screen) is planned but not built yet.

Conventions

  • All code, comments, and documentation are in English.
  • See CHANGELOG.md for what's shipped per phase, and ROADMAP.md for what's next.