- TypeScript 97.5%
- JavaScript 1.7%
- Dockerfile 0.5%
- Shell 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .github/workflows | ||
| backend | ||
| frontend | ||
| nfc-agent | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| CHANGELOG.md | ||
| docker-compose.prod.yml | ||
| docker-compose.yml | ||
| README.md | ||
| ROADMAP.md | ||
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, andVITE_API_BASE_URLfrom.env.frontendand writes them to the frontend runtime configuration. Create that file next todocker-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_URLto the URL a real user's browser will use to reach the backend — notlocalhost, 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
localhostneeds 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, orhttp://localhost. A plainhttp://deployment on a LAN IP or a real domain throwsBrowserAuthError: crypto_nonexistentand 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 updateVITE_API_BASE_URLto the HTTPS URL and add it as a redirect URI on the Azure AD App Registration, or Microsoft login will reject it withAADSTS500113. - Database migrations are applied automatically on container start — no
manual
docker compose exec backend npx prisma migrate deployneeded after a deploy.backend/docker-entrypoint.shrunsprisma migrate deployfirst 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
backendover the internal Docker network) — uncomment the relevant line indocker-compose.yml, bound to127.0.0.1, if you need direct DB access from the host for debugging. backendexposesGET /health, used by its own Docker healthcheck;frontendonly starts oncebackendreports 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:
- Frontend:
/dev-loginis only registered as a route in Vite dev builds (import.meta.env.DEV, seefrontend/src/App.tsx) — a production build (npm run build) doesn't ship the route or the page at all. - Backend:
POST /auth/dev-loginchecksDEV_AUTH_BYPASS_ENABLEDand returns403unless it is the exact string"true". It defaults to unset/falsein.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:
- Find your machine's local network IP (e.g.
ip addr/ifconfigon Linux/macOS,ipconfigon Windows — something like192.168.1.42). - Start the frontend exposed on the network instead of just
localhost:cd frontend npm run dev -- --host - Point the frontend at the backend via that same IP instead of
localhost— editfrontend/.env.local:
(restartVITE_API_BASE_URL=http://192.168.1.42:3001npm run devafter changing it — Vite only reads.env.localon startup) - Make sure the backend's CORS (
app.enableCors()inbackend/src/main.ts) and firewall allow that — the defaultenableCors()with no origin restriction already does. - 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--hostto 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:
- A staff/admin, authenticated with a normal application JWT, calls
POST /nfc/station-tokenwith asessionId. This mints a short-lived, scope-limited station token (NFC_STATION_TOKEN_EXPIRES_IN, default4h) that only grants access toPOST /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. - The reader post —
nfc-agent/running on the staff member's machine (or you, testing with curl) — callsPOST /nfc/scanwith{ "uid": "<card uid>" }, authenticated with the station token — not a normal JWT. ThesessionIdis never in the request body, it comes from the station token itself. - 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 existingnfcWebSocket 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:
- Run
npx ts-node backend/scripts/read-nfc-uid.tswith the reader plugged in, and tap the student's card. It prints the card's UID. - Open
npx prisma studio, find the student's row in theStudenttable, and paste the UID into thenfcUidcolumn.
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.mdfor what's shipped per phase, andROADMAP.mdfor what's next.