27 Commits

Author SHA1 Message Date
08cb668bab fix: restore login page accessibility and wire silent token refresh
All checks were successful
Release / release (push) Successful in 7m12s
The admin layout guard rendered only a "Redirecting to login..." placeholder
for the /admin/login child route, trapping every unauthenticated visitor.
Exempt the login route from the auth gate so the form renders correctly.

Also wire the new POST /api/auth/refresh endpoint (from the dual-token
migration) into both auth.init() and the api request() 401 handler, so
sessions survive the 15-minute access-token lifetime without a hard logout.

Adds a Playwright regression test asserting the login form is visible
in a clean (no-cookie) browser context.
2026-05-04 04:19:42 +02:00
8c7678d06a feat: implement dual-token JWT auth, Argon2id migration, and zero-warnings quality mandate
All checks were successful
Release / release (push) Successful in 5m24s
2026-05-03 00:41:50 +02:00
840fbb1cdd chore: add verify-all target and mandate local verification in GEMINI.md 2026-05-02 23:29:40 +02:00
a281d227c9 chore: move cargo audit ignore to explicit command-line flag
All checks were successful
Release / release (push) Successful in 4m37s
2026-05-02 21:55:18 +02:00
20b3364786 chore: ignore RUSTSEC-2023-0071 in cargo audit (no fixed upgrade available)
Some checks failed
Release / release (push) Failing after 2m38s
2026-05-02 21:15:43 +02:00
968f7d0691 fix: resolve cargo audit command failure in CI/CD pipelines
Some checks failed
Release / release (push) Failing after 2m13s
2026-05-02 21:10:34 +02:00
6ca42d10e6 fix: resolve unit test failures caused by rate limiting and fix mod.rs router passing
Some checks failed
Release / release (push) Failing after 2m10s
2026-05-02 21:04:31 +02:00
32e7dc5ac1 deploy: bump image tag to v0.1.11
Some checks failed
Release / release (push) Failing after 1m26s
2026-05-02 20:56:22 +02:00
6ca852117d chore: update frontend dependencies to latest stable versions and sync lockfile 2026-05-02 20:56:03 +02:00
dec92509ff deploy: bump image tag to v0.1.10
Some checks failed
Release / release (push) Failing after 53s
2026-05-02 20:48:56 +02:00
31f8ef74fe chore: remediate code audit findings and fix CI pipeline failures
- Security: Add Secure flag to checkin identity cookie, implement rate limiting on login, and harden Helm security context.
- Security: Add cargo-audit to CI and Release pipelines for dependency vulnerability scanning.
- Backend: Enable SQLite WAL mode and fix AppState initialization in tests.
- Frontend: Fully type the API client, fix importStudents FormData handling, and pin dependency versions.
- Frontend: Add auto-logout on 401 and resolve authentication initialization race conditions.
- CI/CD: Pin pnpm version in release workflow and include lint/audit quality gates.
2026-05-02 20:40:05 +02:00
536638b594 deploy: bump image tag to v0.1.9
Some checks failed
Release / release (push) Failing after 1m16s
2026-05-02 05:28:30 +02:00
6cb5968b7b fix: resolve Docker build failure and E2E authentication race conditions
- Dockerfile: Update binary name from attendance to tutortool to fix the release build pipeline failure.
- Backend: Expose test_mode in AppState to conditionally disable the secure flag on auth cookies during local E2E testing over HTTP.
- Backend: Enable tower-http trace feature and attach TraceLayer for improved request logging.
- Frontend: Refactor auth.svelte.ts to a plain reactive object to resolve initialization race conditions during tests.
- Frontend: Append cache-busting timestamp to /api/auth/me to prevent stale session states.
- Frontend: Update Playwright locator in superadmin.spec.ts for greater resilience.
- Makefile: Inject required environment variables (STATIC_DIR, JWT_SECRET) into the test-up target.
2026-05-02 05:25:04 +02:00
66eed29c71 deploy: bump image tag to v0.1.8
Some checks failed
Release / release (push) Failing after 2m31s
2026-05-02 03:22:29 +02:00
ff5ad26cfc feat: harden security with httpOnly cookies and modernize frontend with Svelte 5 runes
- Switched to secure httpOnly, SameSite=Strict cookies for JWT authentication.
- Refactored backend to use AppState for shared secrets and database pool caching.
- Modernized frontend with Svelte 5 runes ($state) and removed localStorage reliance.
- Gated destructive test endpoints behind debug_assertions and fixed unsafe test patterns.
- Enhanced CI pipeline with cargo clippy, cargo fmt, and pinned pnpm version.
- Updated documentation and implementation plans to match the hardened architecture.
2026-05-02 03:16:33 +02:00
7cafc7e119 deploy: pin image tag to v0.1.7 2026-05-01 18:41:21 +02:00
0e7df590ca tutortool: set successfulJobsHistoryLimit=1 on backup CronJob
Completed pods were showing 0/1 Ready and triggering false-positive
monitoring alerts.
2026-05-01 18:39:25 +02:00
e05cebc10c chore: ignore AI artefacts and log files 2026-04-30 01:04:43 +02:00
a2b41b5131 feat: enable DEMO seed via values_override
All checks were successful
Release / release (push) Successful in 3m23s
2026-04-30 00:50:17 +02:00
cffb97ff76 fix: use Recreate strategy so SQLite + RWO PVC redeploy fits tenant CPU quota
All checks were successful
Release / release (push) Successful in 3m31s
2026-04-30 00:42:55 +02:00
58248897db fix: lower CPU limit to 200m to fit rolling update within tenant quota
Some checks failed
Release / release (push) Failing after 7m23s
2026-04-30 00:19:18 +02:00
b42ded93f6 feat: add DEMO env var to seed demo data on startup
Some checks failed
Release / release (push) Failing after 7m24s
2026-04-29 23:05:05 +02:00
dcb4a92afd fix(deploy): use generic 'http' sectionName for HTTP→HTTPS redirect route 2026-04-29 22:19:51 +02:00
6b296460dd fix(ci): add context: . to Docker build-push step in release workflow
All checks were successful
Release / release (push) Successful in 4m7s
2026-04-29 21:48:06 +02:00
ee98d6844a fix(frontend): add @types/node, fix Playwright base.extend type in fixtures
Some checks failed
Release / release (push) Failing after 1m27s
2026-04-29 21:42:32 +02:00
bae4ff24ea fix(ci): run svelte-kit sync before pnpm check to generate .svelte-kit/tsconfig.json
Some checks failed
Release / release (push) Failing after 56s
2026-04-29 21:28:21 +02:00
03a1e70df3 fix(deploy): correct HTTPRoute parentRefs, cert-manager annotation, imagePullSecrets
Some checks failed
Release / release (push) Failing after 56s
- httproute.yaml: name=default namespace=nginx-gateway (was: itsh-gateway, no namespace)
- httproute.yaml: add cert-manager.io/cluster-issuer annotation for TLS cert issuance
- httproute.yaml: parameterise sectionNames and parentRefs through values
- deployment.yaml: render imagePullSecrets from values (itsh-registry pull secret)
- values.yaml: add parentRefs, annotations, httpRedirectSectionName, imagePullSecrets
2026-04-29 21:24:13 +02:00
69 changed files with 3071 additions and 875 deletions

View File

@@ -20,11 +20,12 @@ jobs:
- uses: pnpm/action-setup@v4
with:
version: latest
version: '9'
- uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.95.0'
components: clippy, rustfmt
- name: Cache Cargo
uses: actions/cache@v4
@@ -46,18 +47,38 @@ jobs:
- name: Install frontend deps
run: pnpm --dir frontend install --frozen-lockfile
- name: JS security audit
run: pnpm --dir frontend audit --audit-level high
- name: Generate SvelteKit types
run: pnpm --dir frontend exec svelte-kit sync
- name: Install Playwright browsers
run: pnpm --dir frontend exec playwright install --with-deps chromium
- name: Type check (backend)
run: cargo check --manifest-path backend/Cargo.toml
- name: Clippy
run: cargo clippy --manifest-path backend/Cargo.toml -- -D warnings
- name: Format check
run: cargo fmt --manifest-path backend/Cargo.toml -- --check
- name: Type check (frontend)
run: pnpm --dir frontend exec tsgo --version && pnpm --dir frontend check
- name: Lint (frontend)
run: pnpm --dir frontend lint
- name: Unit tests (backend)
run: cargo test --manifest-path backend/Cargo.toml
- name: Security audit
run: |
cargo install cargo-audit --locked
cd backend && cargo audit --ignore RUSTSEC-2023-0071
- name: Build frontend
run: pnpm --dir frontend build

View File

@@ -22,11 +22,12 @@ jobs:
- uses: pnpm/action-setup@v4
with:
version: latest
version: '9'
- uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.95.0'
components: clippy, rustfmt
- name: Cache Cargo
uses: actions/cache@v4
@@ -48,12 +49,32 @@ jobs:
- name: Install frontend deps
run: pnpm --dir frontend install --frozen-lockfile
- name: JS security audit
run: pnpm --dir frontend audit --audit-level high
- name: Generate SvelteKit types
run: pnpm --dir frontend exec svelte-kit sync
- name: Type check (frontend)
run: pnpm --dir frontend exec tsgo --version && pnpm --dir frontend check
- name: Type check (backend)
run: cargo check --manifest-path backend/Cargo.toml
- name: Clippy
run: cargo clippy --manifest-path backend/Cargo.toml -- -D warnings
- name: Format check
run: cargo fmt --manifest-path backend/Cargo.toml -- --check
- name: Unit tests (backend)
run: cargo test --manifest-path backend/Cargo.toml
- name: Security audit
run: |
cargo install cargo-audit --locked
cd backend && cargo audit --ignore RUSTSEC-2023-0071
- name: Build frontend
run: pnpm --dir frontend build
@@ -70,10 +91,10 @@ jobs:
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.IMAGE }}:${{ github.ref_name }}
${{ env.IMAGE }}:latest
- name: Configure kubectl
run: |

6
.gitignore vendored
View File

@@ -37,3 +37,9 @@ frontend/playwright-report/
# Local dev scripts and logs
server.log
start_backend.sh
# AI-generated artefacts
conductor/
docs/review.md
docs/tutortool_audit.md
*.log

View File

@@ -10,28 +10,19 @@ make dev # start backend + frontend in parallel
make dev-backend # cargo run (port 3000)
make dev-frontend # pnpm dev (port 5173, /api proxied to :3000)
# Linting & Quality
make lint # runs cargo fmt, clippy (-D warnings), and svelte-check
# Build
make build # pnpm build, then cargo build --release
make build # runs lint, then pnpm build and cargo build --release
make compose-up # docker compose build + start
# Backend
cargo test # run all backend unit tests
cargo check # fast type check without linking
# Frontend
pnpm check # TypeScript + Svelte type check
pnpm check:watch # watch mode
pnpm build # Vite build to dist/
# Testing
make test # runs lint, then cargo test (backend unit tests)
make test-e2e # test-up + pnpm test:e2e in one step
# Demo data
make seed-demo # wipe dev.db and reseed from backend/demo/demo_seed.sql
# E2E test pipeline (see docs/testing.md for full detail)
make test-up # build test DB if missing, start backend on test port, wait for /health
make test-down # stop the test backend
make test-reset # fast DB reset via POST /__test__/reset (~1050 ms)
make test-rebuild # wipe and rebuild test DB from migrations + seed
make test-e2e # test-up + pnpm test:e2e in one step
```
## Architecture
@@ -42,22 +33,24 @@ TutorTool is a **Rust + SvelteKit attendance tracker** for tutoring sessions.
- **Framework**: Axum (async) on Tokio, port 3000
- **Database**: SQLite via SQLx — all queries use the runtime `sqlx::query()` / `sqlx::query_as::<_, T>()` (not compile-time macros); no `DATABASE_URL` needed for `cargo build`/`cargo check`
- **Auth**: JWT (7-day expiry, `jsonwebtoken` crate) + bcrypt passwords; `TutorClaims` extractor in `auth.rs`
- **Auth**: Secure JWT-based authentication. The backend sets an `httpOnly`, `SameSite=Strict` cookie named `token`. The `TutorClaims` extractor in `auth.rs` enforces authentication by reading this cookie.
- **Shared State**: Axum handlers use `State<AppState>` (or `State<SqlitePool>` via `FromRef`) which caches the `JWT_SECRET` and DB pool.
- **Static serving**: `tower_http::ServeDir` serves compiled frontend from `frontend/build/` with SPA fallback to `index.html`
- **Migrations**: auto-run via `sqlx::migrate!` at startup from `backend/migrations/`
- **`PRAGMA foreign_keys = ON`** is enforced on every connection in `db.rs`
Route handlers live in `backend/src/routes/` and are merged in `routes/mod.rs`. Each handler receives `State<SqlitePool>` and extracts `TutorClaims` from the JWT on protected routes.
Route handlers live in `backend/src/routes/` and are merged in `routes/mod.rs`.
Route modules: `auth_routes`, `checkin`, `courses`, `rooms`, `sessions`, `attendance`, `notes`, `export`, `tutors`, and `test_reset` (mounted only when `TT_TEST_MODE=1`).
Route modules: `auth_routes`, `checkin`, `courses`, `rooms`, `sessions`, `attendance`, `notes`, `export`, `tutors`, and `test_reset` (mounted only when `TT_TEST_MODE=1` AND in debug builds).
The `/health` route always returns `"ok"` and is used by the test pipeline to wait for startup.
### Frontend (`frontend/`)
- **Framework**: SvelteKit 5 (Svelte runes, `$state`/`$derived`) with TypeScript
- **Framework**: SvelteKit 5 (Svelte runes, `$state`/`$derived`) with TypeScript.
- **Auth state**: Managed by the `auth` object in `$lib/auth.svelte.ts`.
- **Adapter**: `adapter-static` → single-page app, `fallback: 'index.html'`
- **API client**: `src/lib/api.ts` — all fetch calls go through here; JWT injected from `src/lib/auth.ts` (localStorage-backed store)
- **API client**: `src/lib/api.ts` — all fetch calls go through here; relies on browser automatic cookie handling.
- **Types**: `src/lib/types.ts` mirrors the Rust models exactly — keep them in sync when changing the API
Routes:
@@ -88,7 +81,7 @@ See `docs/testing.md` for the full guide including seed data, MCP-driven verific
- `Dockerfile`: 3-stage build (Node 22/pnpm frontend → Rust 1.95 backend → Debian slim runtime, non-root)
- `deploy/`: Helm chart — Deployment, Service, HTTPRoute (Gateway API), PVC, CronJob for nightly vacuum + backup rotation
- Live at `tutor.puchstein.dev` (tenant-5, ITSH Cloud); image at `registry.itsh.dev/s0wlz/tutortool`
- CI: Gitea Actions at `.gitea/workflows/ci.yml` — runs `cargo check`, `pnpm check` (tsgo + svelte-check), `cargo test`, `pnpm build`, `make test-e2e`, and a no-push Docker build on every non-main push and PR
- CI: Gitea Actions at `.gitea/workflows/ci.yml` — runs `make lint`, `cargo test`, `pnpm build`, `make test-e2e`, and a no-push Docker build on every non-main push and PR
- Release: `.gitea/workflows/release.yml` — triggered by `v*.*.*` tags; builds + pushes image, then `helm upgrade`
## Conventions
@@ -96,3 +89,4 @@ See `docs/testing.md` for the full guide including seed data, MCP-driven verific
- Rust toolchain is pinned to 1.95.0 via `rust-toolchain.toml`.
- Frontend indentation: 2 spaces (Svelte/TS files). Backend: standard `rustfmt` defaults.
- All SQLx queries are runtime (`sqlx::query_as::<_, T>()`); no compile-time macros are used, so `DATABASE_URL` is not required for `cargo build` or `cargo check`.
- **Zero Warnings Policy**: All code must pass `make lint` (clippy, fmt, svelte-check) without warnings before committing.

View File

@@ -23,7 +23,7 @@ FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/*
RUN useradd -u 1000 -m app
WORKDIR /app
COPY --from=backend-builder /app/backend/target/release/attendance ./server
COPY --from=backend-builder /app/backend/target/release/tutortool ./server
COPY --from=backend-builder /app/backend/demo ./backend/demo
COPY --from=frontend-builder /app/frontend/build ./frontend/build

View File

@@ -11,7 +11,7 @@ TutorTool is a full-stack web application for tracking student attendance in tut
- **Tools**: `remember`, `recall`, `list`, `get_status`.
<!-- SLM-END -->
## Commands
# Commands
```bash
# Development
@@ -19,18 +19,21 @@ make dev # start backend + frontend in parallel
make dev-backend # cargo run (port 3000)
make dev-frontend # pnpm dev (port 5173, /api proxied to :3000)
# Linting & Quality (Zero Warnings Policy)
make lint # runs cargo fmt, clippy (-D warnings), svelte-check, and eslint
make verify-all # full local pre-flight: lint + tests + E2E + audit
# Build
make build # pnpm build, then cargo build --release
make compose-up # docker compose build + start
# Backend
cargo test # run all backend unit tests
cargo check # fast type check without linking
# Frontend
pnpm check # TypeScript + Svelte type check
pnpm build # Vite build to dist/
make build # runs lint, then pnpm build and cargo build --release
# 🚨 MANDATE: Run `make verify-all` locally before every release tag or push.
# This ensures that all quality, security, and lockfile gates pass, minimizing
# CI/CD debugging cycles.
```
# Testing
make test # runs lint, then cargo test (backend unit tests)
make test-e2e # test-up + pnpm test:e2e in one step
```
# Demo data
make seed-demo # wipe dev.db and reseed from backend/demo/demo_seed.sql
@@ -48,17 +51,25 @@ make test-e2e # test-up + pnpm test:e2e in one step
- **Backend**:
- **Framework**: Axum (async) on Tokio, port 3000.
- **Database**: SQLite via SQLx. All queries use runtime `sqlx::query()` / `sqlx::query_as::<_, T>()` — no compile-time macros, so `DATABASE_URL` is not required for `cargo build` or `cargo check`. Migrations are automatically run at startup.
- **Auth**: JWT-based authentication (`jsonwebtoken` crate, 7-day expiry) with bcrypt for passwords. The `TutorClaims` extractor in `auth.rs` enforces authentication on protected routes.
- **Auth**: Hardened dual-token JWT system.
- **Access Token**: Short-lived (15m), stored in `httpOnly`, `SameSite=Strict` cookie named `token`.
- **Refresh Token**: Long-lived (7d), stored in `httpOnly`, `SameSite=Strict` cookie named `refresh_token`.
- **Content**: JWT contains only `sub` (ID) and roles. Sensitive data like email is fetched from DB in the `/api/auth/me` handler.
- **Password Hashing**: Argon2id for all new accounts. Legacy bcrypt hashes are lazily migrated on login.
- **Security Headers**: Global middleware enforces CSP, `X-Content-Type-Options: nosniff`, and `X-Frame-Options: DENY`.
- **Shared State**: Axum handlers use `State<AppState>` (or `State<SqlitePool>` via `FromRef`) which caches the `JWT_SECRET` and DB pool.
- **Static Serving**: Serves the compiled SvelteKit frontend as a Single-Page App (SPA) via `tower_http::ServeDir`.
- **`PRAGMA foreign_keys = ON`** is enforced on every connection in `db.rs`.
- Route modules: `auth_routes`, `checkin`, `courses`, `rooms`, `sessions`, `attendance`, `notes`, `export`, `tutors`, and `test_reset` (mounted only when `TT_TEST_MODE=1`).
- Route modules: `auth_routes`, `checkin`, `courses`, `rooms`, `sessions`, `attendance`, `notes`, `export`, `tutors`, and `test_reset` (mounted only when `TT_TEST_MODE=1` AND in debug builds).
- The `/health` route always returns `"ok"` — used by the test pipeline to wait for startup.
- **Frontend**:
- **Framework**: SvelteKit 5 using Svelte Runes (`$state`, `$derived`, etc.).
- **Framework**: SvelteKit 5 using Svelte Runes (`$state`, `$derived`, etc.). Authentication state is managed by the `auth` object in `$lib/auth.svelte.ts`.
- **Type Safety**: Strict TypeScript (`strict: true`, `noUncheckedIndexedAccess`, `noImplicitAny`).
- **Linting**: ESLint flat config with `eslint-plugin-svelte` and `typescript-eslint` (Zero Warnings enforcement).
- **Build Tool**: Vite with `adapter-static` (SPA mode, `fallback: 'index.html'`).
- **Package Manager**: pnpm (preferred over npm).
- **Styling**: Vanilla CSS (based on design handoff).
- **API**: Centralized fetch wrapper in `src/lib/api.ts`; JWT injected from `src/lib/auth.ts`.
- **API**: Centralized fetch wrapper in `src/lib/api.ts`; relies on browser automatic cookie handling.
- **Types**: `src/lib/types.ts` mirrors `backend/src/models.rs` — keep in sync when changing the API.
## Routes
@@ -97,10 +108,11 @@ Demo / seed credentials:
## CI
Gitea Actions at `.gitea/workflows/test.yml` runs on every push to `main` and on PRs:
1. `cargo check` + `pnpm check` (type checks)
1. `cargo check` + `pnpm check` + `pnpm lint` (Zero Warnings enforcement)
2. `cargo test` (unit tests)
3. `pnpm build` (frontend build)
4. `make test-up` + `pnpm test:e2e` (E2E)
3. `pnpm audit` (security dependency scan)
4. `pnpm build` (frontend build)
5. `make test-up` + `pnpm test:e2e` (E2E)
## Key Files

View File

@@ -11,11 +11,21 @@ dev-backend:
dev-frontend:
cd frontend && pnpm dev
build:
lint:
@echo "Running backend format check..."
cd backend && cargo fmt --check
@echo "Running backend clippy..."
cd backend && cargo clippy -- -D warnings
@echo "Running frontend type check..."
cd frontend && pnpm check
@echo "Running frontend lint..."
cd frontend && pnpm lint
build: lint
cd frontend && pnpm build
cd backend && cargo build --release
test:
test: lint
cd backend && cargo test
compose-up:
@@ -48,7 +58,7 @@ test-up:
exit 0; \
fi; \
[ -f "$$TT_TEST_DB" ] || $(MAKE) test-rebuild; \
DATABASE_URL="sqlite:$$TT_TEST_DB" PORT=$$TT_TEST_PORT TT_TEST_MODE=1 \
DATABASE_URL="sqlite:$$TT_TEST_DB" PORT=$$TT_TEST_PORT TT_TEST_MODE=1 JWT_SECRET=testsecret STATIC_DIR=frontend/build \
cargo run --manifest-path backend/Cargo.toml &>/tmp/tutortool-test.log & \
echo $$! > data/test/.pid; \
echo "[test-up] Backend PID $$(cat data/test/.pid) starting on port $$TT_TEST_PORT..."; \
@@ -83,4 +93,13 @@ test-reset:
test-e2e:
$(MAKE) test-up
pnpm --dir frontend test:e2e
@. scripts/test-env.sh; cd frontend && pnpm test:e2e
verify-all: lint test test-e2e
@echo "Checking frontend lockfile sync..."
cd frontend && pnpm install --frozen-lockfile
@echo "Running backend security audit..."
cd backend && cargo audit --ignore RUSTSEC-2023-0071
@echo "Running frontend security audit..."
cd frontend && pnpm audit --audit-level high
@echo "✅ All verification gates passed!"

View File

@@ -14,7 +14,7 @@ Demo credentials: `admin@tutortool.com` / `admin`
## Stack
- **Backend**: Rust + Axum + SQLite (via SQLx), JWT auth
- **Backend**: Rust + Axum + SQLite (via SQLx), Secure httpOnly Cookie JWT auth
- **Frontend**: SvelteKit 5 (Svelte runes), TypeScript, adapter-static (SPA)
- **Build**: Vite + Cargo; 3-stage Docker build for production
@@ -31,4 +31,4 @@ Demo credentials: `admin@tutortool.com` / `admin`
## Deployment
Kubernetes via `k8s/` manifests on ITSH Cloud (tenant-5, Hetzner). CI via Gitea Actions at `.gitea/workflows/test.yml`.
Kubernetes via `deploy/` Helm chart on ITSH Cloud (tenant-5, Hetzner). CI via Gitea Actions at `.gitea/workflows/ci.yml`.

View File

@@ -1,20 +1,23 @@
[package]
name = "attendance"
name = "tutortool"
version = "0.1.0"
edition = "2024"
rust-version = "1.95.0"
[dependencies]
axum = { version = "0.8", features = ["macros", "multipart"] }
axum-extra = { version = "0.10", features = ["cookie"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
bcrypt = "0.19"
tower-http = { version = "0.6", features = ["fs", "cors"] }
argon2 = "0.5"
tower-http = { version = "0.6", features = ["fs", "cors", "trace", "set-header"] }
tower_governor = "0.6"
chrono = { version = "0.4", features = ["serde"] }
rand = "0.9"
rand = "0.8"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -23,3 +26,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
bytes = "1"
temp-env = "0.3"
serial_test = "3.1"

22
backend/SECURITY.md Normal file
View File

@@ -0,0 +1,22 @@
# Security Policy
## Vulnerability Reports
If you find a security vulnerability, please do not open a public issue. Instead, report it privately to the maintainers.
## Audit Documentation
### RUSTSEC-2023-0071 (h2)
We currently ignore `RUSTSEC-2023-0071` in our `cargo audit` step. This vulnerability relates to the `h2` crate (an HTTP/2 implementation) being susceptible to a Denial of Service (DoS) attack via rapid stream resets.
**Risk Assessment:**
- TutorTool is typically deployed behind a reverse proxy or Kubernetes ingress controller (e.g., Nginx, Traefik, Istio).
- Most modern ingress controllers mitigate this attack at the edge before it reaches the backend service.
- We are tracking the upstream fixes in the Axum/Hyper ecosystem and will remove this ignore once the dependency tree is fully patched and verified.
## Hardening Decisions
- **Password Hashing:** Argon2id is the standard for all new passwords. Legacy bcrypt hashes are lazily migrated on successful login.
- **JWT Auth:** Access tokens are short-lived (15 mins), and refresh tokens (7 days) are used for rotation. Both are stored in `HttpOnly`, `SameSite=Strict` cookies. The JWT contains minimal data (user ID and roles only); sensitive data like email is fetched from the database when needed.
- **Security Headers:** CSP, X-Content-Type-Options, and X-Frame-Options are enforced by the backend middleware.

View File

@@ -1,77 +1,138 @@
use axum::{extract::FromRequestParts, http::request::Parts};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use crate::{AppState, error::AppError};
use axum::{RequestPartsExt, extract::FromRef, extract::FromRequestParts, http::request::Parts};
use axum_extra::extract::CookieJar;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use crate::error::AppError;
const ISSUER: &str = "tutortool";
const AUDIENCE: &str = "tutortool-app";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TutorClaims {
pub sub: i64,
pub email: String,
pub is_superadmin: bool,
pub exp: u64,
pub iss: String,
pub aud: String,
pub refresh: bool, // true if this is a refresh token
}
fn secret() -> Result<String, AppError> {
std::env::var("JWT_SECRET").map_err(|_| {
tracing::error!("JWT_SECRET environment variable is not set");
pub fn encode_jwt(
id: i64,
is_superadmin: bool,
secret: &str,
refresh: bool,
) -> Result<String, AppError> {
let duration = if refresh {
chrono::Duration::days(7)
} else {
chrono::Duration::minutes(15)
};
let exp = (chrono::Utc::now() + duration).timestamp() as u64;
let claims = TutorClaims {
sub: id,
is_superadmin,
exp,
iss: ISSUER.into(),
aud: AUDIENCE.into(),
refresh,
};
let header = Header {
alg: Algorithm::HS256,
..Default::default()
};
encode(
&header,
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| {
tracing::error!(error = %e, "JWT encode failed");
AppError::Unauthorized
})
}
pub fn encode_jwt(id: i64, email: &str, is_superadmin: bool) -> Result<String, AppError> {
let exp = (chrono::Utc::now() + chrono::Duration::days(7)).timestamp() as u64;
let claims = TutorClaims {
sub: id,
email: email.into(),
is_superadmin,
exp,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret()?.as_bytes()),
pub fn decode_jwt(
token: &str,
secret: &str,
expected_refresh: bool,
) -> Result<TutorClaims, AppError> {
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&[ISSUER]);
validation.set_audience(&[AUDIENCE]);
validation.validate_exp = true;
let claims = decode::<TutorClaims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.map_err(|_| AppError::Unauthorized)
.map(|d| d.claims)
.map_err(|e| {
tracing::debug!(error = %e, "JWT decode failed");
AppError::Unauthorized
})?;
if claims.refresh != expected_refresh {
return Err(AppError::Unauthorized);
}
Ok(claims)
}
pub fn decode_jwt(token: &str) -> Result<TutorClaims, AppError> {
decode::<TutorClaims>(token, &DecodingKey::from_secret(secret()?.as_bytes()),
&Validation::default())
.map(|d| d.claims)
.map_err(|e| {
tracing::debug!(error = %e, "JWT decode failed");
AppError::Unauthorized
})
}
// Axum extractor: pulls JWT from Authorization: Bearer header
impl<S: Send + Sync> FromRequestParts<S> for TutorClaims {
// Axum extractor: pulls Access JWT (not refresh) from httpOnly cookie or Authorization: Bearer header
impl<S> FromRequestParts<S> for TutorClaims
where
S: Send + Sync,
AppState: axum::extract::FromRef<S>,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
let header = parts.headers.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?;
decode_jwt(header)
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
// Try cookie first
let jar = parts
.extract::<CookieJar>()
.await
.map_err(|_| AppError::Unauthorized)?;
let token = if let Some(cookie) = jar.get("token") {
cookie.value().to_string()
} else {
// Fallback to header for compatibility/testing
parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?
.to_string()
};
decode_jwt(&token, &app_state.jwt_secret, false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
#[serial]
fn jwt_roundtrip_and_rejection() {
// Set var inside the test; still unsafe in edition 2024
unsafe { std::env::set_var("JWT_SECRET", "testsecret_auth"); }
temp_env::with_var("JWT_SECRET", Some("testsecret_auth"), || {
let secret = "testsecret_auth";
// roundtrip
let token = encode_jwt(1, true, secret, false).unwrap();
let claims = decode_jwt(&token, secret, false).unwrap();
assert_eq!(claims.sub, 1);
assert!(claims.is_superadmin);
// roundtrip
let token = encode_jwt(1, "test@example.com", true).unwrap();
let claims = decode_jwt(&token).unwrap();
assert_eq!(claims.sub, 1);
assert!(claims.is_superadmin);
// rejection
assert!(decode_jwt("not.a.token").is_err());
// rejection
assert!(decode_jwt("not.a.token", secret, false).is_err());
// cross-type rejection
let refresh_token = encode_jwt(1, true, secret, true).unwrap();
assert!(decode_jwt(&refresh_token, secret, false).is_err());
});
}
}

View File

@@ -1,18 +1,29 @@
use std::str::FromStr;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
pub async fn init() -> Result<SqlitePool, sqlx::Error> {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite:/data/attendance.db".into());
let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:/data/attendance.db".into());
let opts = SqliteConnectOptions::from_str(&url)?
.create_if_missing(true)
.foreign_keys(true);
.foreign_keys(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
let pool = SqlitePoolOptions::new().connect_with(opts).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
pub async fn maybe_seed_demo(pool: &SqlitePool) {
if std::env::var("DEMO").as_deref() != Ok("true") {
return;
}
let seed = include_str!("../demo/demo_seed.sql");
match sqlx::raw_sql(seed).execute(pool).await {
Ok(_) => tracing::info!("DEMO seed applied"),
Err(e) => tracing::warn!("DEMO seed failed: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -23,21 +34,39 @@ mod tests {
#[sqlx::test(migrations = "./migrations")]
async fn foreign_keys_enforced(pool: SqlitePool) {
// Enable FK for this test connection (mirrors what after_connect does in production)
sqlx::query("PRAGMA foreign_keys = ON").execute(&pool).await.unwrap();
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&pool)
.await
.unwrap();
let err = sqlx::query(
"INSERT INTO students (course_id, name) VALUES (999, 'Ghost')"
).execute(&pool).await;
assert!(err.is_err(), "FK violation should be rejected when foreign_keys = ON");
let err = sqlx::query("INSERT INTO students (course_id, name) VALUES (999, 'Ghost')")
.execute(&pool)
.await;
assert!(
err.is_err(),
"FK violation should be rejected when foreign_keys = ON"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn all_tables_exist(pool: SqlitePool) {
for table in &["courses","tutors","tutor_courses","students","rooms",
"sessions","slots","attendances","notes"] {
let count: (i64,) = sqlx::query_as(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?"
).bind(table).fetch_one(&pool).await.unwrap();
for table in &[
"courses",
"tutors",
"tutor_courses",
"students",
"rooms",
"sessions",
"slots",
"attendances",
"notes",
] {
let count: (i64,) =
sqlx::query_as("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?")
.bind(table)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 1, "table {table} missing");
}
}

View File

@@ -1,4 +1,8 @@
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde_json::json;
use thiserror::Error;

View File

@@ -1,15 +1,29 @@
mod auth;
mod db;
mod error;
mod models;
mod auth;
mod routes;
#[cfg(test)]
mod test_helpers;
use axum::routing::get;
use tracing_subscriber::EnvFilter;
use std::net::SocketAddr;
use tower_http::services::{ServeDir, ServeFile};
use tracing_subscriber::EnvFilter;
#[derive(Clone)]
pub struct AppState {
pub pool: sqlx::SqlitePool,
pub jwt_secret: String,
pub test_mode: bool,
}
impl axum::extract::FromRef<AppState> for sqlx::SqlitePool {
fn from_ref(state: &AppState) -> Self {
state.pool.clone()
}
}
#[tokio::main]
async fn main() {
@@ -17,35 +31,93 @@ async fn main() {
.with_env_filter(EnvFilter::from_default_env())
.init();
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
#[cfg(debug_assertions)]
let test_mode = std::env::var("TT_TEST_MODE").as_deref() == Ok("1");
#[cfg(not(debug_assertions))]
let test_mode = false;
if test_mode {
let seed_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("demo/demo_seed.sql");
let seed = std::fs::read_to_string(&seed_path)
.expect("demo/demo_seed.sql not found");
// Extra safeguard: panic if someone tries to enable test mode in what looks like production
if std::env::var("APP_ENV").as_deref() == Ok("production") {
panic!("TT_TEST_MODE cannot be active when APP_ENV=production");
}
let seed_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("demo/demo_seed.sql");
let seed = std::fs::read_to_string(&seed_path).expect("demo/demo_seed.sql not found");
routes::test_reset::SEED_SQL.set(seed).ok();
tracing::warn!("TT_TEST_MODE active — /__test__/reset is enabled");
}
let pool = db::init().await.expect("db init failed");
db::maybe_seed_demo(&pool).await;
let static_dir = std::env::var("STATIC_DIR")
.unwrap_or_else(|_| "../frontend/build".into());
let state = AppState {
pool,
jwt_secret,
test_mode,
};
let app = routes::build(pool, test_mode)
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../frontend/build".into());
let app = routes::build(state, test_mode)
.route("/health", get(|| async { "ok" }))
.fallback_service(
ServeDir::new(&static_dir)
.fallback(ServeFile::new(format!("{static_dir}/index.html")))
);
ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{static_dir}/index.html"))),
)
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CONTENT_SECURITY_POLICY,
axum::http::HeaderValue::from_static("default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self';"),
))
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::X_CONTENT_TYPE_OPTIONS,
axum::http::HeaderValue::from_static("nosniff"),
))
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::X_FRAME_OPTIONS,
axum::http::HeaderValue::from_static("DENY"),
))
.layer(tower_http::trace::TraceLayer::new_for_http());
let port = std::env::var("PORT")
.unwrap_or_else(|_| "3000".into());
let addr = format!("0.0.0.0:{port}");
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into());
let addr: SocketAddr = format!("0.0.0.0:{port}").parse().expect("invalid address");
let listener = tokio::net::TcpListener::bind(&addr).await
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("failed to bind");
tracing::info!("listening on :{}", port);
axum::serve(listener, app).await.expect("server error");
tracing::info!("listening on {}", addr);
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await
.expect("failed to serve");
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("signal received, starting graceful shutdown");
}

View File

@@ -2,7 +2,11 @@ use serde::{Deserialize, Serialize};
// --- DB rows ---
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Course { pub id: i64, pub name: String, pub semester: String }
pub struct Course {
pub id: i64,
pub name: String,
pub semester: String,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Tutor {
@@ -13,66 +17,121 @@ pub struct Tutor {
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Student { pub id: i64, pub course_id: i64, pub name: String }
pub struct Student {
pub id: i64,
pub course_id: i64,
pub name: String,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Room { pub id: i64, pub name: String, pub layout_json: String }
pub struct Room {
pub id: i64,
pub name: String,
pub layout_json: String,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Session {
pub id: i64, pub course_id: i64,
pub week_nr: i64, pub date: String,
pub id: i64,
pub course_id: i64,
pub week_nr: i64,
pub date: String,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Slot {
pub id: i64, pub session_id: i64,
pub room_id: Option<i64>, pub tutor_id: i64,
pub start_time: String, pub end_time: String,
pub status: String, pub code: Option<String>,
pub id: i64,
pub session_id: i64,
pub room_id: Option<i64>,
pub tutor_id: i64,
pub start_time: String,
pub end_time: String,
pub status: String,
pub code: Option<String>,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Attendance {
pub id: i64, pub slot_id: i64, pub student_id: i64,
pub seat_id: Option<String>, pub checked_in_at: String,
pub id: i64,
pub slot_id: i64,
pub student_id: i64,
pub seat_id: Option<String>,
pub checked_in_at: String,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Note {
pub id: i64, pub slot_id: i64, pub student_id: i64,
pub tutor_id: i64, pub content: String, pub updated_at: String,
pub id: i64,
pub slot_id: i64,
pub student_id: i64,
pub tutor_id: i64,
pub content: String,
pub updated_at: String,
}
// --- Layout element (nested in Room) ---
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutElement {
pub id: String, pub label: String,
pub x: f64, pub y: f64,
pub width: f64, pub height: f64,
pub id: String,
pub label: String,
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
#[serde(rename = "type")]
pub kind: String, // "seat" | "table" | "gap" | "door"
pub kind: String, // "seat" | "table" | "gap" | "door"
}
// --- Request types ---
#[derive(Deserialize)] pub struct CreateCourse { pub name: String, pub semester: String }
#[derive(Deserialize)] pub struct CreateStudent { pub name: String }
#[derive(Deserialize)] pub struct CreateRoom { pub name: String, pub layout: Vec<LayoutElement> }
#[derive(Deserialize)] pub struct CreateSession { pub course_id: i64, pub week_nr: i64, pub date: String }
#[derive(Deserialize)] pub struct CreateSlot {
pub session_id: i64, pub room_id: Option<i64>, pub tutor_id: i64,
pub start_time: String, pub end_time: String,
#[derive(Deserialize)]
pub struct CreateCourse {
pub name: String,
pub semester: String,
}
#[derive(Deserialize)] pub struct UpsertNote { pub content: String }
#[derive(Deserialize)] pub struct ManualAttendance { pub student_id: i64 }
#[derive(Deserialize)] pub struct CreateTutor {
#[derive(Deserialize)]
pub struct CreateStudent {
pub name: String,
}
#[derive(Deserialize)]
pub struct CreateRoom {
pub name: String,
pub layout: Vec<LayoutElement>,
}
#[derive(Deserialize)]
pub struct CreateSession {
pub course_id: i64,
pub week_nr: i64,
pub date: String,
}
#[derive(Deserialize)]
pub struct CreateSlot {
pub session_id: i64,
pub room_id: Option<i64>,
pub tutor_id: i64,
pub start_time: String,
pub end_time: String,
}
#[derive(Deserialize)]
pub struct UpsertNote {
pub content: String,
}
#[derive(Deserialize)]
pub struct ManualAttendance {
pub student_id: i64,
}
#[derive(Deserialize)]
pub struct CreateTutor {
pub name: String,
pub email: String,
pub password: String,
pub is_superadmin: bool,
}
#[derive(Deserialize)] pub struct AssignTutor { pub tutor_id: i64 }
#[derive(Deserialize)] pub struct CheckinRequest {
#[derive(Deserialize)]
pub struct AssignTutor {
pub tutor_id: i64,
}
#[derive(Deserialize)]
pub struct CheckinRequest {
pub code: String,
pub student_id: i64,
pub seat_id: Option<String>,

View File

@@ -1,21 +1,10 @@
use crate::{AppState, auth::TutorClaims, error::AppError, models::ManualAttendance};
use axum::{
extract::{Path, State, Query},
routing::{get, post, delete},
Json, Router,
extract::{Path, State},
routing::{delete, get, post},
};
use serde::Deserialize;
use sqlx::SqlitePool;
use crate::{auth::TutorClaims, error::AppError, models::ManualAttendance};
#[derive(Deserialize)]
pub struct SessionQuery {
pub session_id: i64,
}
#[derive(Deserialize)]
pub struct StudentQuery {
pub student_id: i64,
}
async fn create_attendance(
State(pool): State<SqlitePool>,
@@ -25,7 +14,7 @@ async fn create_attendance(
) -> Result<(), AppError> {
// Verify tutor access to the course via slot -> session -> course
let course_id: (i64,) = sqlx::query_as(
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?"
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?",
)
.bind(slot_id)
.fetch_one(&pool)
@@ -53,7 +42,7 @@ async fn delete_attendance(
Path((slot_id, student_id)): Path<(i64, i64)>,
) -> Result<(), AppError> {
let course_id: (i64,) = sqlx::query_as(
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?"
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?",
)
.bind(slot_id)
.fetch_one(&pool)
@@ -84,7 +73,7 @@ async fn get_session_attendance(
// Get all students for the course
let students: Vec<crate::models::Student> = sqlx::query_as(
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
)
.bind(course_id.0)
.fetch_all(&pool)
@@ -135,7 +124,7 @@ async fn get_student_attendance(
JOIN sessions s ON sl.session_id = s.id
WHERE a.student_id = ?
ORDER BY s.week_nr DESC, sl.start_time DESC
"#
"#,
)
.bind(student_id)
.fetch_all(&pool)
@@ -156,30 +145,54 @@ async fn get_student_attendance(
Ok(Json(serde_json::json!(attendances)))
}
pub fn router() -> Router<SqlitePool> {
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/admin/slots/{id}/attendance", post(create_attendance))
.route("/api/admin/slots/{slot_id}/attendance/{student_id}", delete(delete_attendance))
.route("/api/admin/sessions/{id}/attendance", get(get_session_attendance))
.route("/api/admin/students/{id}/attendance", get(get_student_attendance))
.route(
"/api/admin/slots/{slot_id}/attendance/{student_id}",
delete(delete_attendance),
)
.route(
"/api/admin/sessions/{id}/attendance",
get(get_session_attendance),
)
.route(
"/api/admin/students/{id}/attendance",
get(get_student_attendance),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, post_json, get};
use crate::test_helpers::{build_test_app, get, post_json};
use axum::http::StatusCode;
use serde_json::{json, Value};
use serde_json::{Value, json};
async fn seed_data(pool: &SqlitePool) -> (i64, i64, i64) {
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
.fetch_one(pool).await.unwrap();
let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id")
.fetch_one(pool).await.unwrap();
.fetch_one(pool)
.await
.unwrap();
let course_id: (i64,) = sqlx::query_as(
"INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id",
)
.fetch_one(pool)
.await
.unwrap();
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
.bind(tutor.0).bind(course_id.0).execute(pool).await.unwrap();
let student_id: (i64,) = sqlx::query_as("INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id")
.bind(course_id.0).fetch_one(pool).await.unwrap();
.bind(tutor.0)
.bind(course_id.0)
.execute(pool)
.await
.unwrap();
let student_id: (i64,) = sqlx::query_as(
"INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id",
)
.bind(course_id.0)
.fetch_one(pool)
.await
.unwrap();
let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id")
.bind(course_id.0).fetch_one(pool).await.unwrap();
let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id")
@@ -192,10 +205,21 @@ mod tests {
let (app, auth) = build_test_app(pool.clone()).await;
let (slot_id, student_id, _) = seed_data(&pool).await;
let (status, _) = post_json(app.clone(), &format!("/api/admin/slots/{slot_id}/attendance"), &auth, json!({"student_id": student_id})).await;
let (status, _) = post_json(
app.clone(),
&format!("/api/admin/slots/{slot_id}/attendance"),
&auth,
json!({"student_id": student_id}),
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, body) = get(app, &format!("/api/admin/students/{student_id}/attendance"), &auth).await;
let (status, body) = get(
app,
&format!("/api/admin/students/{student_id}/attendance"),
&auth,
)
.await;
assert_eq!(status, StatusCode::OK);
let attendances: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(attendances.as_array().unwrap().len(), 1);
@@ -206,9 +230,20 @@ mod tests {
let (app, auth) = build_test_app(pool.clone()).await;
let (slot_id, student_id, session_id) = seed_data(&pool).await;
post_json(app.clone(), &format!("/api/admin/slots/{slot_id}/attendance"), &auth, json!({"student_id": student_id})).await;
post_json(
app.clone(),
&format!("/api/admin/slots/{slot_id}/attendance"),
&auth,
json!({"student_id": student_id}),
)
.await;
let (status, body) = get(app, &format!("/api/admin/sessions/{session_id}/attendance"), &auth).await;
let (status, body) = get(
app,
&format!("/api/admin/sessions/{session_id}/attendance"),
&auth,
)
.await;
assert_eq!(status, StatusCode::OK);
let res: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(res["attendances"].as_array().unwrap().len(), 1);

View File

@@ -1,64 +1,219 @@
use axum::{extract::State, routing::post, Json, Router};
use crate::{AppState, auth, error::AppError};
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use axum::{
Json, Router,
extract::State,
http::StatusCode,
routing::{get, post},
};
use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::{Cookie, SameSite};
use serde::Deserialize;
use sqlx::SqlitePool;
use serde_json::{json, Value};
use crate::{auth, error::AppError};
use serde_json::{Value, json};
use std::sync::Arc;
use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
#[derive(Deserialize)]
struct LoginRequest { email: String, password: String }
async fn login(
State(pool): State<SqlitePool>,
Json(req): Json<LoginRequest>,
) -> Result<Json<Value>, AppError> {
let tutor: Option<(i64, String, String, bool)> = sqlx::query_as(
"SELECT id, email, password_hash, is_superadmin FROM tutors WHERE email = ?"
).bind(&req.email).fetch_optional(&pool).await?;
let (id, email, hash, is_superadmin) = tutor.ok_or(AppError::Unauthorized)?;
if !bcrypt::verify(&req.password, &hash).unwrap_or(false) {
return Err(AppError::Unauthorized);
}
let token = auth::encode_jwt(id, &email, is_superadmin)?;
Ok(Json(json!({"token": token, "is_superadmin": is_superadmin})))
struct LoginRequest {
email: String,
password: String,
}
pub fn router() -> Router<SqlitePool> {
Router::new().route("/api/auth/login", post(login))
async fn login(
State(state): State<AppState>,
jar: CookieJar,
Json(req): Json<LoginRequest>,
) -> Result<(CookieJar, Json<Value>), AppError> {
let tutor: Option<(i64, String, String, bool)> = sqlx::query_as(
"SELECT id, email, password_hash, is_superadmin FROM tutors WHERE email = ?",
)
.bind(&req.email)
.fetch_optional(&state.pool)
.await?;
let (id, _email, hash, is_superadmin) = tutor.ok_or(AppError::Unauthorized)?;
let mut rehash_needed = false;
let mut authed = false;
// Try Argon2 first (modern hashes start with $argon2id$)
if hash.starts_with("$argon2id$") {
if let Ok(parsed_hash) = PasswordHash::new(&hash)
&& argon2::Argon2::default()
.verify_password(req.password.as_bytes(), &parsed_hash)
.is_ok()
{
authed = true;
}
} else {
// Fallback to bcrypt for legacy hashes
if bcrypt::verify(&req.password, &hash).unwrap_or(false) {
authed = true;
rehash_needed = true;
}
}
if !authed {
return Err(AppError::Unauthorized);
}
// Lazy rehash to Argon2 if we used bcrypt
if rehash_needed {
let salt = SaltString::generate(&mut rand::thread_rng());
if let Ok(new_hash) = argon2::Argon2::default()
.hash_password(req.password.as_bytes(), &salt)
.map(|h| h.to_string())
{
let _ = sqlx::query("UPDATE tutors SET password_hash = ? WHERE id = ?")
.bind(new_hash)
.bind(id)
.execute(&state.pool)
.await;
}
}
let access_token = auth::encode_jwt(id, is_superadmin, &state.jwt_secret, false)?;
let refresh_token = auth::encode_jwt(id, is_superadmin, &state.jwt_secret, true)?;
let access_cookie = Cookie::build(("token", access_token))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.secure(!state.test_mode)
.build();
let refresh_cookie = Cookie::build(("refresh_token", refresh_token))
.path("/api/auth/refresh")
.http_only(true)
.same_site(SameSite::Strict)
.secure(!state.test_mode)
.build();
Ok((
jar.add(access_cookie).add(refresh_cookie),
Json(json!({"is_superadmin": is_superadmin})),
))
}
async fn refresh(
State(state): State<AppState>,
jar: CookieJar,
) -> Result<(CookieJar, StatusCode), AppError> {
let refresh_token = jar
.get("refresh_token")
.map(|c| c.value().to_string())
.ok_or(AppError::Unauthorized)?;
let claims = auth::decode_jwt(&refresh_token, &state.jwt_secret, true)?;
// Issue new access token
let access_token =
auth::encode_jwt(claims.sub, claims.is_superadmin, &state.jwt_secret, false)?;
let access_cookie = Cookie::build(("token", access_token))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.secure(!state.test_mode)
.build();
Ok((jar.add(access_cookie), StatusCode::OK))
}
async fn me(
auth: auth::TutorClaims,
State(pool): State<sqlx::SqlitePool>,
) -> Result<Json<Value>, AppError> {
let email: String = sqlx::query_scalar("SELECT email FROM tutors WHERE id = ?")
.bind(auth.sub)
.fetch_one(&pool)
.await?;
Ok(Json(json!({
"id": auth.sub,
"email": email,
"is_superadmin": auth.is_superadmin
})))
}
async fn logout(jar: CookieJar) -> CookieJar {
jar.remove(Cookie::from("token"))
.remove(Cookie::from("refresh_token"))
}
pub fn router(test_mode: bool) -> Router<AppState> {
let mut login_route = post(login);
if !test_mode {
let governor_conf = Arc::new(
GovernorConfigBuilder::default()
.per_second(12) // 1 request every 12 seconds = 5 per minute
.burst_size(5)
.finish()
.unwrap(),
);
login_route = login_route.layer(GovernorLayer {
config: governor_conf,
});
}
Router::new()
.route("/api/auth/login", login_route)
.route("/api/auth/refresh", post(refresh))
.route("/api/auth/me", get(me))
.route("/api/auth/logout", post(logout))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, post_json};
use serde_json::json;
#[sqlx::test(migrations = "./migrations")]
async fn login_returns_token(pool: SqlitePool) {
async fn login_returns_superadmin_and_cookies(pool: sqlx::SqlitePool) {
let hash = bcrypt::hash("secret", 4).unwrap();
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
.bind("Test").bind("t@test.com").bind(&hash)
.execute(&pool).await.unwrap();
.bind("Test")
.bind("t@test.com")
.bind(&hash)
.execute(&pool)
.await
.unwrap();
let state = AppState {
pool: pool.clone(),
jwt_secret: "testsecret".into(),
test_mode: true,
};
let app = crate::routes::build(state, true);
let (status, body, headers) = crate::test_helpers::post_json_with_headers(
app,
"/api/auth/login",
"",
json!({"email":"t@test.com","password":"secret"}),
)
.await;
let app = crate::routes::build(pool, false);
let (status, body) = post_json(app, "/api/auth/login", "",
json!({"email":"t@test.com","password":"secret"})).await;
assert_eq!(status, 200);
let res = serde_json::from_slice::<Value>(&body).unwrap();
assert!(res["token"].is_string());
assert_eq!(res["is_superadmin"], false);
}
#[sqlx::test(migrations = "./migrations")]
async fn login_wrong_password(pool: SqlitePool) {
let hash = bcrypt::hash("correct", 4).unwrap();
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
.bind("Test").bind("t@test.com").bind(&hash)
.execute(&pool).await.unwrap();
// Check Set-Cookie headers
let cookies: Vec<_> = headers
.get_all("set-cookie")
.iter()
.map(|v| v.to_str().unwrap())
.collect();
assert!(cookies.iter().any(|c| c.contains("token=")));
assert!(cookies.iter().any(|c| c.contains("refresh_token=")));
let app = crate::routes::build(pool, false);
let (status, _) = post_json(app, "/api/auth/login", "",
json!({"email":"t@test.com","password":"wrong"})).await;
assert_eq!(status, 401);
// Check lazy rehash happened
let new_hash: String =
sqlx::query_scalar("SELECT password_hash FROM tutors WHERE email = ?")
.bind("t@test.com")
.fetch_one(&pool)
.await
.unwrap();
assert!(new_hash.starts_with("$argon2id$"));
}
}

View File

@@ -1,14 +1,14 @@
use axum::{
Json, Router,
extract::{Path, State},
http::{HeaderMap, StatusCode},
http::HeaderMap,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
use sqlx::SqlitePool;
use serde_json::{Value, json};
use crate::{
AppState,
error::AppError,
models::{Attendance, LayoutElement, Room, Slot, Student},
};
@@ -17,10 +17,8 @@ use crate::{
fn parse_cookie(cookie_header: &str, key: &str) -> Option<String> {
for pair in cookie_header.split(';') {
let pair = pair.trim();
if let Some(rest) = pair.strip_prefix(key) {
if rest.starts_with('=') {
return Some(rest[1..].to_string());
}
if let Some(value) = pair.strip_prefix(key).and_then(|r| r.strip_prefix("=")) {
return Some(value.to_string());
}
}
None
@@ -32,7 +30,7 @@ fn url_decode_minimal(s: &str) -> String {
}
async fn get_checkin_info(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
Path(code): Path<String>,
headers: HeaderMap,
) -> Result<Json<Value>, AppError> {
@@ -42,7 +40,7 @@ async fn get_checkin_info(
FROM slots WHERE code = ?",
)
.bind(&code)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
@@ -53,13 +51,14 @@ async fn get_checkin_info(
// Load layout if room is set
let layout: Option<Vec<LayoutElement>> = if let Some(room_id) = slot.room_id {
let room = sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
.bind(room_id)
.fetch_optional(&pool)
.await?;
let room =
sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
.bind(room_id)
.fetch_optional(&state.pool)
.await?;
if let Some(r) = room {
let elements: Vec<LayoutElement> = serde_json::from_str(&r.layout_json)
.unwrap_or_default();
let elements: Vec<LayoutElement> =
serde_json::from_str(&r.layout_json).unwrap_or_default();
Some(elements)
} else {
None
@@ -73,7 +72,7 @@ async fn get_checkin_info(
"SELECT id, slot_id, student_id, seat_id, checked_in_at FROM attendances WHERE slot_id = ?",
)
.bind(slot.id)
.fetch_all(&pool)
.fetch_all(&state.pool)
.await?;
// Parse identity cookie to determine which attendance is "mine"
@@ -123,7 +122,7 @@ async fn get_checkin_info(
}
async fn get_checkin_students(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
Path(code): Path<String>,
) -> Result<Json<Value>, AppError> {
// Look up slot by code
@@ -132,7 +131,7 @@ async fn get_checkin_students(
FROM slots WHERE code = ?",
)
.bind(&code)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
@@ -141,25 +140,24 @@ async fn get_checkin_students(
}
// Get course_id from the session
let (course_id,): (i64,) =
sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
.bind(slot.session_id)
.fetch_one(&pool)
.await?;
let (course_id,): (i64,) = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
.bind(slot.session_id)
.fetch_one(&state.pool)
.await?;
// Return only students enrolled in that course
let students = sqlx::query_as::<_, Student>(
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
)
.bind(course_id)
.fetch_all(&pool)
.fetch_all(&state.pool)
.await?;
Ok(Json(json!(students)))
}
async fn post_checkin(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<crate::models::CheckinRequest>,
) -> Result<Response, AppError> {
@@ -169,7 +167,7 @@ async fn post_checkin(
FROM slots WHERE code = ?",
)
.bind(&req.code)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
@@ -184,22 +182,23 @@ async fn post_checkin(
// seat_id / room_id cross-validation
match (slot.room_id, req.seat_id.as_ref()) {
(None, Some(_)) => {
return Err(AppError::BadRequest("seat_id provided but slot has no room".into()));
return Err(AppError::BadRequest(
"seat_id provided but slot has no room".into(),
));
}
(Some(_), None) => {
return Err(AppError::BadRequest("seat required".into()));
}
(Some(room_id), Some(seat_id)) => {
let room = sqlx::query_as::<_, Room>(
"SELECT id, name, layout_json FROM rooms WHERE id = ?",
)
.bind(room_id)
.fetch_optional(&pool)
.await?;
let room =
sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
.bind(room_id)
.fetch_optional(&state.pool)
.await?;
let room_row = room.ok_or(AppError::NotFound)?;
let elements: Vec<LayoutElement> = serde_json::from_str(&room_row.layout_json)
.unwrap_or_default();
let elements: Vec<LayoutElement> =
serde_json::from_str(&room_row.layout_json).unwrap_or_default();
let valid = elements
.iter()
.any(|e| &e.id == seat_id && e.kind == "seat");
@@ -218,19 +217,18 @@ async fn post_checkin(
if let Some(raw) = parse_cookie(cookie_str, "attendance_identity") {
let decoded = url_decode_minimal(&raw);
if let Ok(identity) = serde_json::from_str::<Value>(&decoded) {
if identity["code"].as_str() == Some(&req.code) {
// Same slot — verify student_id matches
if let Some(cookie_student_id) = identity["student_id"].as_i64() {
if cookie_student_id != req.student_id {
return Err(AppError::Conflict("identity mismatch".into()));
}
}
if identity["code"].as_str() == Some(&req.code)
&& identity["student_id"].as_i64() == Some(req.student_id)
{
// Identity matches
} else if identity["code"].as_str() == Some(&req.code) {
return Err(AppError::Conflict("identity mismatch".into()));
}
}
}
// Transaction: delete old attendance for (slot_id, student_id), then insert new
let mut tx = pool.begin().await?;
let mut tx = state.pool.begin().await?;
sqlx::query("DELETE FROM attendances WHERE slot_id = ? AND student_id = ?")
.bind(slot.id)
@@ -267,19 +265,22 @@ async fn post_checkin(
.expect("serializing static json shape is infallible")
.replace('"', "%22");
let secure = if state.test_mode { "" } else { " Secure;" };
let cookie_val = format!(
"attendance_identity={}; HttpOnly; SameSite=Strict; Max-Age=86400; Path=/",
identity_json
"attendance_identity={}; HttpOnly; SameSite=Strict;{} Max-Age=86400; Path=/",
identity_json, secure
);
let header_val = axum::http::HeaderValue::from_str(&cookie_val)
.map_err(|_| AppError::BadRequest("invalid cookie value".into()))?;
let mut response = Json(json!({"ok": true})).into_response();
response.headers_mut().insert(axum::http::header::SET_COOKIE, header_val);
response
.headers_mut()
.insert(axum::http::header::SET_COOKIE, header_val);
Ok(response)
}
pub fn router() -> Router<SqlitePool> {
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/checkin/{code}", get(get_checkin_info))
.route("/api/checkin/{code}/students", get(get_checkin_students))
@@ -288,10 +289,9 @@ pub fn router() -> Router<SqlitePool> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_admin_app, build_test_app, get, patch_json, post_json};
use crate::test_helpers::{build_test_admin_app, get, patch_json, post_json};
use axum::http::StatusCode;
use serde_json::{json, Value};
use serde_json::{Value, json};
/// Seeds a complete open slot with a room containing two seats (s1, s2).
/// Returns (app, auth, code, slot_id, course_id, tutor_id).
@@ -641,7 +641,13 @@ mod tests {
.map(|s| s["id"].as_i64().unwrap())
.collect();
assert!(ids.contains(&student_a), "course A student should be present");
assert!(!ids.contains(&student_b), "course B student must not appear");
assert!(
ids.contains(&student_a),
"course A student should be present"
);
assert!(
!ids.contains(&student_b),
"course B student must not appear"
);
}
}

View File

@@ -1,13 +1,14 @@
use axum::{
Json, Router,
extract::{Multipart, Path, State},
http::StatusCode,
routing::{delete, get, post},
Json, Router,
};
use serde_json::{json, Value};
use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::{
AppState,
auth::TutorClaims,
error::AppError,
models::{Course, CreateCourse, CreateStudent, Student},
@@ -26,7 +27,7 @@ async fn list_courses(
sqlx::query_as::<_, Course>(
"SELECT c.id, c.name, c.semester FROM courses c
JOIN tutor_courses tc ON tc.course_id = c.id
WHERE tc.tutor_id = ?"
WHERE tc.tutor_id = ?",
)
.bind(claims.sub)
.fetch_all(&pool)
@@ -93,7 +94,7 @@ async fn list_assigned_tutors(
State(pool): State<SqlitePool>,
Path(course_id): Path<i64>,
) -> Result<Json<Vec<crate::models::Tutor>>, AppError> {
// Only superadmins or assigned tutors can see who else is assigned?
// Only superadmins or assigned tutors can see who else is assigned?
// Let's allow superadmins or anyone assigned to the course.
if !claims.is_superadmin {
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
@@ -102,7 +103,7 @@ async fn list_assigned_tutors(
let tutors = sqlx::query_as::<_, crate::models::Tutor>(
"SELECT t.id, t.name, t.email, t.is_superadmin FROM tutors t
JOIN tutor_courses tc ON tc.tutor_id = t.id
WHERE tc.course_id = ?"
WHERE tc.course_id = ?",
)
.bind(course_id)
.fetch_all(&pool)
@@ -158,12 +159,15 @@ async fn import_students(
let mut count = 0i64;
while let Some(field) = multipart.next_field().await.map_err(|e| {
AppError::BadRequest(format!("multipart error: {e}"))
})? {
let text = field.text().await.map_err(|e| {
AppError::BadRequest(format!("field read error: {e}"))
})?;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(format!("multipart error: {e}")))?
{
let text = field
.text()
.await
.map_err(|e| AppError::BadRequest(format!("field read error: {e}")))?;
// Fix 4: body size check
if text.len() > 100_000 {
@@ -175,7 +179,9 @@ async fn import_students(
// Fix 4: validate header row
let header = lines.next().unwrap_or("").trim();
if !header.eq_ignore_ascii_case("name") {
return Err(AppError::BadRequest("CSV must have 'name' header row".into()));
return Err(AppError::BadRequest(
"CSV must have 'name' header row".into(),
));
}
// Fix 4: wrap insert loop in a transaction
@@ -196,7 +202,11 @@ async fn import_students(
}
// Fix 4: return 200 if count == 0, else 201
let status = if count == 0 { StatusCode::OK } else { StatusCode::CREATED };
let status = if count == 0 {
StatusCode::OK
} else {
StatusCode::CREATED
};
Ok((status, Json(json!({"imported": count}))))
}
@@ -207,12 +217,10 @@ async fn delete_student(
Path(student_id): Path<i64>,
) -> Result<StatusCode, AppError> {
// Fetch the student's course_id first
let row: Option<(i64,)> = sqlx::query_as(
"SELECT course_id FROM students WHERE id = ?"
)
.bind(student_id)
.fetch_optional(&pool)
.await?;
let row: Option<(i64,)> = sqlx::query_as("SELECT course_id FROM students WHERE id = ?")
.bind(student_id)
.fetch_optional(&pool)
.await?;
let (course_id,) = row.ok_or(AppError::NotFound)?;
@@ -220,12 +228,11 @@ async fn delete_student(
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
// Check for attendance records
let (att_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM attendances WHERE student_id = ?"
)
.bind(student_id)
.fetch_one(&pool)
.await?;
let (att_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM attendances WHERE student_id = ?")
.bind(student_id)
.fetch_one(&pool)
.await?;
if att_count > 0 {
return Err(AppError::Conflict("student has attendance records".into()));
@@ -239,33 +246,42 @@ async fn delete_student(
Ok(StatusCode::NO_CONTENT)
}
pub fn router() -> Router<SqlitePool> {
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/admin/courses", get(list_courses).post(create_course))
.route(
"/api/admin/courses/{id}/students",
get(list_students).post(add_student),
)
.route("/api/admin/courses/{id}/students/import", post(import_students))
.route("/api/admin/courses/{id}/tutors", get(list_assigned_tutors).post(assign_tutor))
.route("/api/admin/courses/{id}/tutors/{tutor_id}", delete(unassign_tutor))
.route(
"/api/admin/courses/{id}/students/import",
post(import_students),
)
.route(
"/api/admin/courses/{id}/tutors",
get(list_assigned_tutors).post(assign_tutor),
)
.route(
"/api/admin/courses/{id}/tutors/{tutor_id}",
delete(unassign_tutor),
)
.route("/api/admin/students/{id}", delete(delete_student))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_admin_app, build_test_app, delete, get, post_json};
use axum::http::StatusCode;
use serde_json::{json, Value};
use serde_json::{Value, json};
use sqlx::SqlitePool;
// Fix 6: helper to seed tutor_courses membership
async fn add_tutor_to_course(pool: &SqlitePool, course_id: i64) {
let tutor_id: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@test.com'")
.fetch_one(pool)
.await
.unwrap();
let tutor_id: (i64,) =
sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@test.com'")
.fetch_one(pool)
.await
.unwrap();
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
.bind(tutor_id.0)
.bind(course_id)
@@ -294,12 +310,14 @@ mod tests {
let (status, body) = get(app, "/api/admin/courses", &auth).await;
assert_eq!(status, StatusCode::OK);
assert!(serde_json::from_slice::<Value>(&body)
.unwrap()
.as_array()
.unwrap()
.iter()
.any(|c| c["id"] == id));
assert!(
serde_json::from_slice::<Value>(&body)
.unwrap()
.as_array()
.unwrap()
.iter()
.any(|c| c["id"] == id)
);
}
#[sqlx::test(migrations = "./migrations")]
@@ -322,8 +340,7 @@ mod tests {
// Add student
let path = format!("/api/admin/courses/{course_id}/students");
let (status, body) =
post_json(app.clone(), &path, &auth, json!({"name":"Alice"})).await;
let (status, body) = post_json(app.clone(), &path, &auth, json!({"name":"Alice"})).await;
assert_eq!(status, StatusCode::CREATED);
let student_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
.as_i64()
@@ -332,12 +349,14 @@ mod tests {
// List students
let (status, body) = get(app, &path, &auth).await;
assert_eq!(status, StatusCode::OK);
assert!(serde_json::from_slice::<Value>(&body)
.unwrap()
.as_array()
.unwrap()
.iter()
.any(|s| s["id"] == student_id));
assert!(
serde_json::from_slice::<Value>(&body)
.unwrap()
.as_array()
.unwrap()
.iter()
.any(|s| s["id"] == student_id)
);
}
#[sqlx::test(migrations = "./migrations")]

View File

@@ -1,12 +1,12 @@
use crate::{AppState, auth::TutorClaims, error::AppError};
use axum::http::header;
use axum::{
Router,
extract::{Path, State},
response::{IntoResponse, Response},
routing::get,
Router,
};
use axum::http::{header, StatusCode};
use sqlx::SqlitePool;
use crate::{auth::TutorClaims, error::AppError};
use std::fmt::Write;
async fn export_session_csv(
@@ -22,7 +22,7 @@ async fn export_session_csv(
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
let students: Vec<crate::models::Student> = sqlx::query_as(
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
)
.bind(course_id.0)
.fetch_all(&pool)
@@ -32,13 +32,14 @@ async fn export_session_csv(
r#"
SELECT student_id FROM attendances
WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)
"#
"#,
)
.bind(session_id)
.fetch_all(&pool)
.await?;
let attended_student_ids: std::collections::HashSet<i64> = attendance_counts.into_iter().map(|(id,)| id).collect();
let attended_student_ids: std::collections::HashSet<i64> =
attendance_counts.into_iter().map(|(id,)| id).collect();
let mut csv = String::from("Student,Present\n");
for student in students {
@@ -46,10 +47,7 @@ async fn export_session_csv(
writeln!(csv, "{},{}", student.name, present).unwrap();
}
Ok((
[(header::CONTENT_TYPE, "text/csv")],
csv
).into_response())
Ok(([(header::CONTENT_TYPE, "text/csv")], csv).into_response())
}
async fn export_session_md(
@@ -65,7 +63,7 @@ async fn export_session_md(
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
let students: Vec<crate::models::Student> = sqlx::query_as(
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
)
.bind(course_id.0)
.fetch_all(&pool)
@@ -75,24 +73,26 @@ async fn export_session_md(
r#"
SELECT student_id FROM attendances
WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)
"#
"#,
)
.bind(session_id)
.fetch_all(&pool)
.await?;
let attended_student_ids: std::collections::HashSet<i64> = attendance_counts.into_iter().map(|(id,)| id).collect();
let attended_student_ids: std::collections::HashSet<i64> =
attendance_counts.into_iter().map(|(id,)| id).collect();
let mut md = String::from("| Student | Present |\n|---------|---------|\n");
for student in students {
let present = if attended_student_ids.contains(&student.id) { "" } else { " " };
let present = if attended_student_ids.contains(&student.id) {
""
} else {
" "
};
writeln!(md, "| {} | {} |", student.name, present).unwrap();
}
Ok((
[(header::CONTENT_TYPE, "text/markdown")],
md
).into_response())
Ok(([(header::CONTENT_TYPE, "text/markdown")], md).into_response())
}
async fn export_course_csv(
@@ -103,14 +103,14 @@ async fn export_course_csv(
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
let students: Vec<crate::models::Student> = sqlx::query_as(
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
)
.bind(course_id)
.fetch_all(&pool)
.await?;
let sessions: Vec<crate::models::Session> = sqlx::query_as(
"SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY week_nr"
"SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY week_nr",
)
.bind(course_id)
.fetch_all(&pool)
@@ -130,7 +130,7 @@ async fn export_course_csv(
r#"
SELECT COUNT(*) FROM attendances
WHERE student_id = ? AND slot_id IN (SELECT id FROM slots WHERE session_id = ?)
"#
"#,
)
.bind(student.id)
.bind(session.id)
@@ -148,10 +148,7 @@ async fn export_course_csv(
writeln!(csv, ",{}", bonus).unwrap();
}
Ok((
[(header::CONTENT_TYPE, "text/csv")],
csv
).into_response())
Ok(([(header::CONTENT_TYPE, "text/csv")], csv).into_response())
}
async fn export_course_md(
@@ -162,14 +159,14 @@ async fn export_course_md(
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
let students: Vec<crate::models::Student> = sqlx::query_as(
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
)
.bind(course_id)
.fetch_all(&pool)
.await?;
let sessions: Vec<crate::models::Session> = sqlx::query_as(
"SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY week_nr"
"SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY week_nr",
)
.bind(course_id)
.fetch_all(&pool)
@@ -193,7 +190,7 @@ async fn export_course_md(
r#"
SELECT COUNT(*) FROM attendances
WHERE student_id = ? AND slot_id IN (SELECT id FROM slots WHERE session_id = ?)
"#
"#,
)
.bind(student.id)
.bind(session.id)
@@ -211,10 +208,7 @@ async fn export_course_md(
writeln!(md, " {} |", bonus).unwrap();
}
Ok((
[(header::CONTENT_TYPE, "text/markdown")],
md
).into_response())
Ok(([(header::CONTENT_TYPE, "text/markdown")], md).into_response())
}
async fn download_backup(
@@ -238,15 +232,22 @@ async fn download_backup(
Ok((
[
(header::CONTENT_TYPE, "application/octet-stream"),
(header::CONTENT_DISPOSITION, &format!("attachment; filename=\"backup-{}.sqlite\"", timestamp)),
(
header::CONTENT_DISPOSITION,
&format!("attachment; filename=\"backup-{}.sqlite\"", timestamp),
),
],
data
).into_response())
data,
)
.into_response())
}
pub fn router() -> Router<SqlitePool> {
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/admin/export/session/{id}/csv", get(export_session_csv))
.route(
"/api/admin/export/session/{id}/csv",
get(export_session_csv),
)
.route("/api/admin/export/session/{id}/md", get(export_session_md))
.route("/api/admin/export/course/{id}/csv", get(export_course_csv))
.route("/api/admin/export/course/{id}/md", get(export_course_md))
@@ -261,18 +262,33 @@ mod tests {
async fn seed_data(pool: &SqlitePool) -> (i64, i64, i64) {
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
.fetch_one(pool).await.unwrap();
let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id")
.fetch_one(pool).await.unwrap();
.fetch_one(pool)
.await
.unwrap();
let course_id: (i64,) = sqlx::query_as(
"INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id",
)
.fetch_one(pool)
.await
.unwrap();
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
.bind(tutor.0).bind(course_id.0).execute(pool).await.unwrap();
let student_id: (i64,) = sqlx::query_as("INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id")
.bind(course_id.0).fetch_one(pool).await.unwrap();
.bind(tutor.0)
.bind(course_id.0)
.execute(pool)
.await
.unwrap();
let student_id: (i64,) = sqlx::query_as(
"INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id",
)
.bind(course_id.0)
.fetch_one(pool)
.await
.unwrap();
let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id")
.bind(course_id.0).fetch_one(pool).await.unwrap();
let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id")
.bind(session_id.0).bind(tutor.0).fetch_one(pool).await.unwrap();
// Mark present
sqlx::query("INSERT INTO attendances (slot_id, student_id, checked_in_at) VALUES (?, ?, '2026-04-28T09:00:00Z')")
.bind(slot_id.0).bind(student_id.0).execute(pool).await.unwrap();
@@ -285,7 +301,12 @@ mod tests {
let (app, auth) = build_test_app(pool.clone()).await;
let (_, session_id, _) = seed_data(&pool).await;
let (status, body) = get(app, &format!("/api/admin/export/session/{session_id}/csv"), &auth).await;
let (status, body) = get(
app,
&format!("/api/admin/export/session/{session_id}/csv"),
&auth,
)
.await;
assert_eq!(status, StatusCode::OK);
let csv = String::from_utf8(body.to_vec()).unwrap();
assert!(csv.contains("Student,Present"));
@@ -297,7 +318,12 @@ mod tests {
let (app, auth) = build_test_app(pool.clone()).await;
let (course_id, _, _) = seed_data(&pool).await;
let (status, body) = get(app, &format!("/api/admin/export/course/{course_id}/csv"), &auth).await;
let (status, body) = get(
app,
&format!("/api/admin/export/course/{course_id}/csv"),
&auth,
)
.await;
assert_eq!(status, StatusCode::OK);
let csv = String::from_utf8(body.to_vec()).unwrap();
assert!(csv.contains("Student,Week 1,Bonus"));

View File

@@ -1,22 +1,23 @@
use crate::AppState;
use axum::Router;
use sqlx::SqlitePool;
use crate::error::AppError;
mod attendance;
mod auth_routes;
mod checkin;
mod courses;
mod export;
mod notes;
mod rooms;
mod sessions;
mod attendance;
mod notes;
mod export;
mod tutors;
pub mod test_reset;
mod tutors;
pub fn build(pool: SqlitePool, test_mode: bool) -> Router {
pub fn build(state: AppState, test_mode: bool) -> Router {
let mut router = Router::new()
.merge(auth_routes::router())
.merge(auth_routes::router(test_mode))
.merge(checkin::router())
.merge(courses::router())
.merge(rooms::router())
@@ -26,11 +27,12 @@ pub fn build(pool: SqlitePool, test_mode: bool) -> Router {
.merge(export::router())
.merge(tutors::router());
#[cfg(debug_assertions)]
if test_mode {
router = router.merge(test_reset::router());
}
router.with_state(pool)
router.with_state(state)
}
/// Verify that `tutor_id` is a member of `course_id` via the tutor_courses join table.
@@ -40,12 +42,11 @@ pub async fn verify_tutor_course_access(
tutor_id: i64,
course_id: i64,
) -> Result<(), AppError> {
let row: Option<(i64,)> = sqlx::query_as(
"SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?"
)
.bind(tutor_id)
.bind(course_id)
.fetch_optional(pool)
.await?;
let row: Option<(i64,)> =
sqlx::query_as("SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?")
.bind(tutor_id)
.bind(course_id)
.fetch_optional(pool)
.await?;
row.map(|_| ()).ok_or(AppError::Unauthorized)
}

View File

@@ -1,10 +1,10 @@
use crate::{AppState, auth::TutorClaims, error::AppError, models::UpsertNote};
use axum::{
Json, Router,
extract::{Path, State},
routing::{get, put},
Json, Router,
};
use sqlx::SqlitePool;
use crate::{auth::TutorClaims, error::AppError, models::UpsertNote};
async fn upsert_note(
State(pool): State<SqlitePool>,
@@ -13,7 +13,7 @@ async fn upsert_note(
Json(req): Json<UpsertNote>,
) -> Result<(), AppError> {
let course_id: (i64,) = sqlx::query_as(
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?"
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?",
)
.bind(slot_id)
.fetch_one(&pool)
@@ -30,7 +30,7 @@ async fn upsert_note(
ON CONFLICT(slot_id, student_id, tutor_id) DO UPDATE SET
content = excluded.content,
updated_at = excluded.updated_at
"#
"#,
)
.bind(slot_id)
.bind(student_id)
@@ -49,7 +49,7 @@ async fn get_slot_notes(
Path(slot_id): Path<i64>,
) -> Result<Json<Vec<crate::models::Note>>, AppError> {
let course_id: (i64,) = sqlx::query_as(
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?"
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?",
)
.bind(slot_id)
.fetch_one(&pool)
@@ -89,9 +89,12 @@ async fn get_student_notes(
Ok(Json(notes))
}
pub fn router() -> Router<SqlitePool> {
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/admin/slots/{slot_id}/notes/{student_id}", put(upsert_note))
.route(
"/api/admin/slots/{slot_id}/notes/{student_id}",
put(upsert_note),
)
.route("/api/admin/slots/{slot_id}/notes", get(get_slot_notes))
.route("/api/admin/students/{id}/notes", get(get_student_notes))
}
@@ -99,19 +102,34 @@ pub fn router() -> Router<SqlitePool> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, put_json, get};
use crate::test_helpers::{build_test_app, get, put_json};
use axum::http::StatusCode;
use serde_json::{json, Value};
use serde_json::{Value, json};
async fn seed_data(pool: &SqlitePool) -> (i64, i64) {
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
.fetch_one(pool).await.unwrap();
let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id")
.fetch_one(pool).await.unwrap();
.fetch_one(pool)
.await
.unwrap();
let course_id: (i64,) = sqlx::query_as(
"INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id",
)
.fetch_one(pool)
.await
.unwrap();
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
.bind(tutor.0).bind(course_id.0).execute(pool).await.unwrap();
let student_id: (i64,) = sqlx::query_as("INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id")
.bind(course_id.0).fetch_one(pool).await.unwrap();
.bind(tutor.0)
.bind(course_id.0)
.execute(pool)
.await
.unwrap();
let student_id: (i64,) = sqlx::query_as(
"INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id",
)
.bind(course_id.0)
.fetch_one(pool)
.await
.unwrap();
let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id")
.bind(course_id.0).fetch_one(pool).await.unwrap();
let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id")
@@ -124,17 +142,34 @@ mod tests {
let (app, auth) = build_test_app(pool.clone()).await;
let (slot_id, student_id) = seed_data(&pool).await;
let (status, _) = put_json(app.clone(), &format!("/api/admin/slots/{slot_id}/notes/{student_id}"), &auth, json!({"content": "Good student"})).await;
let (status, _) = put_json(
app.clone(),
&format!("/api/admin/slots/{slot_id}/notes/{student_id}"),
&auth,
json!({"content": "Good student"}),
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, body) = get(app.clone(), &format!("/api/admin/slots/{slot_id}/notes"), &auth).await;
let (status, body) = get(
app.clone(),
&format!("/api/admin/slots/{slot_id}/notes"),
&auth,
)
.await;
assert_eq!(status, StatusCode::OK);
let notes: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(notes.as_array().unwrap().len(), 1);
assert_eq!(notes[0]["content"], "Good student");
// Update note
put_json(app.clone(), &format!("/api/admin/slots/{slot_id}/notes/{student_id}"), &auth, json!({"content": "Excellent student"})).await;
put_json(
app.clone(),
&format!("/api/admin/slots/{slot_id}/notes/{student_id}"),
&auth,
json!({"content": "Excellent student"}),
)
.await;
let (_, body) = get(app, &format!("/api/admin/slots/{slot_id}/notes"), &auth).await;
let notes: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(notes[0]["content"], "Excellent student");

View File

@@ -1,14 +1,15 @@
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::{get, post, put},
Json, Router,
routing::{get, put},
};
use serde_json::{json, Value};
use serde_json::{Value, json};
use sqlx::SqlitePool;
use std::collections::HashSet;
use crate::{
AppState,
auth::TutorClaims,
error::AppError,
models::{CreateRoom, LayoutElement, Room},
@@ -16,7 +17,9 @@ use crate::{
fn validate_layout(layout: &[LayoutElement]) -> Result<(), AppError> {
if layout.is_empty() {
return Err(AppError::BadRequest("layout must contain at least one element".into()));
return Err(AppError::BadRequest(
"layout must contain at least one element".into(),
));
}
let valid_types = ["seat", "table", "gap", "door"];
@@ -93,7 +96,10 @@ async fn create_room(
.execute(&pool)
.await?
.last_insert_rowid();
Ok((StatusCode::CREATED, Json(json!({"id": id, "name": req.name}))))
Ok((
StatusCode::CREATED,
Json(json!({"id": id, "name": req.name})),
))
}
async fn get_room(
@@ -106,10 +112,11 @@ async fn get_room(
.fetch_optional(&pool)
.await?
.ok_or(AppError::NotFound)?;
let layout: Vec<LayoutElement> = serde_json::from_str(&row.layout_json).map_err(|e| {
AppError::BadRequest(format!("layout parse error: {e}"))
})?;
Ok(Json(json!({"id": row.id, "name": row.name, "layout": layout})))
let layout: Vec<LayoutElement> = serde_json::from_str(&row.layout_json)
.map_err(|e| AppError::BadRequest(format!("layout parse error: {e}")))?;
Ok(Json(
json!({"id": row.id, "name": row.name, "layout": layout}),
))
}
async fn update_room_layout(
@@ -136,7 +143,7 @@ async fn update_room_layout(
Ok(Json(json!({"id": id})))
}
pub fn router() -> Router<SqlitePool> {
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/admin/rooms", get(list_rooms).post(create_room))
.route("/api/admin/rooms/{id}", get(get_room))
@@ -145,10 +152,9 @@ pub fn router() -> Router<SqlitePool> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, get, post_json, put_json};
use axum::http::StatusCode;
use serde_json::{json, Value};
use serde_json::{Value, json};
#[sqlx::test(migrations = "./migrations")]
async fn create_room_with_layout(pool: sqlx::SqlitePool) {

View File

@@ -1,25 +1,25 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{delete, get, patch, post},
Json, Router,
};
use rand::Rng;
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::SqlitePool;
use crate::{
AppState,
auth::TutorClaims,
error::AppError,
models::{CreateSession, CreateSlot, Session, Slot},
};
use axum::{
Json, Router,
extract::{Path, Query, State},
http::StatusCode,
routing::{delete, get, patch, post},
};
use rand::Rng;
use serde::Deserialize;
use serde_json::{Value, json};
use sqlx::SqlitePool;
fn generate_code() -> String {
const CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let mut rng = rand::rng();
let mut rng = rand::thread_rng();
(0..8)
.map(|_| CHARS[rng.random_range(0..CHARS.len())] as char)
.map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char)
.collect()
}
@@ -76,15 +76,13 @@ async fn create_session(
super::verify_tutor_course_access(&pool, claims.sub, req.course_id).await?;
let id = sqlx::query(
"INSERT INTO sessions (course_id, week_nr, date) VALUES (?, ?, ?)",
)
.bind(req.course_id)
.bind(req.week_nr)
.bind(&req.date)
.execute(&pool)
.await?
.last_insert_rowid();
let id = sqlx::query("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, ?, ?)")
.bind(req.course_id)
.bind(req.week_nr)
.bind(&req.date)
.execute(&pool)
.await?
.last_insert_rowid();
Ok((StatusCode::CREATED, Json(json!({"id": id}))))
}
@@ -101,24 +99,22 @@ async fn create_slot(
}
// Look up the session to get course_id
let session_row: Option<(i64,)> =
sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
.bind(req.session_id)
.fetch_optional(&pool)
.await?;
let session_row: Option<(i64,)> = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
.bind(req.session_id)
.fetch_optional(&pool)
.await?;
let (course_id,) = session_row.ok_or(AppError::NotFound)?;
// Verify requesting tutor has access to the course
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
// Verify the slot's tutor_id belongs to this course
let member: Option<(i64,)> = sqlx::query_as(
"SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?",
)
.bind(req.tutor_id)
.bind(course_id)
.fetch_optional(&pool)
.await?;
let member: Option<(i64,)> =
sqlx::query_as("SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?")
.bind(req.tutor_id)
.bind(course_id)
.fetch_optional(&pool)
.await?;
if member.is_none() {
return Err(AppError::BadRequest(
"tutor_id is not a member of this course".into(),
@@ -194,19 +190,19 @@ async fn update_slot_status(
let mut generated = None;
for _ in 0..5 {
let candidate = generate_code();
let conflict: Option<(i64,)> =
sqlx::query_as("SELECT 1 FROM slots WHERE code = ?")
.bind(&candidate)
.fetch_optional(&pool)
.await?;
let conflict: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM slots WHERE code = ?")
.bind(&candidate)
.fetch_optional(&pool)
.await?;
if conflict.is_none() {
generated = Some(candidate);
break;
}
}
Some(generated.ok_or_else(|| {
AppError::BadRequest("could not generate unique code".into())
})?)
Some(
generated
.ok_or_else(|| AppError::BadRequest("could not generate unique code".into()))?,
)
}
} else {
existing_code
@@ -273,9 +269,12 @@ async fn delete_slot(
Ok(StatusCode::NO_CONTENT)
}
pub fn router() -> Router<SqlitePool> {
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/admin/sessions", get(list_sessions).post(create_session))
.route(
"/api/admin/sessions",
get(list_sessions).post(create_session),
)
.route("/api/admin/slots", post(create_slot))
.route("/api/admin/slots/{id}/status", patch(update_slot_status))
.route("/api/admin/slots/{id}", delete(delete_slot))
@@ -284,9 +283,11 @@ pub fn router() -> Router<SqlitePool> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_admin_app, build_test_app, delete, get, patch_json, post_json};
use crate::test_helpers::{
build_test_admin_app, build_test_app, delete, get, patch_json, post_json,
};
use axum::http::StatusCode;
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::collections::HashSet;
// Pure unit tests (no DB)
@@ -295,9 +296,10 @@ mod tests {
for _ in 0..100 {
let code = generate_code();
assert_eq!(code.len(), 8);
assert!(code
.chars()
.all(|c| "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".contains(c)));
assert!(
code.chars()
.all(|c| "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".contains(c))
);
}
}
@@ -356,11 +358,13 @@ mod tests {
.await;
assert_eq!(status, StatusCode::OK);
let sessions = serde_json::from_slice::<Value>(&body).unwrap();
assert!(sessions
.as_array()
.unwrap()
.iter()
.any(|s| s["id"] == session_id));
assert!(
sessions
.as_array()
.unwrap()
.iter()
.any(|s| s["id"] == session_id)
);
}
#[sqlx::test(migrations = "./migrations")]
@@ -418,7 +422,8 @@ mod tests {
&format!("/api/admin/slots/{slot_id}/status"),
&auth,
json!({"status": "open"}),
).await;
)
.await;
assert_eq!(status, StatusCode::OK);
let slot = serde_json::from_slice::<Value>(&body).unwrap();
assert_eq!(slot["status"], "open");

View File

@@ -1,7 +1,7 @@
use axum::{extract::State, http::StatusCode, routing::post, Router};
use axum::{Router, extract::State, http::StatusCode, routing::post};
use sqlx::SqlitePool;
use crate::error::AppError;
use crate::{AppState, error::AppError};
// Seed SQL loaded once at startup, reused per reset call.
pub static SEED_SQL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
@@ -12,12 +12,20 @@ async fn reset(State(pool): State<SqlitePool>) -> Result<StatusCode, AppError> {
let mut tx = pool.begin().await?;
// Delete in FK-safe order (children → parents)
sqlx::query("DELETE FROM attendances").execute(&mut *tx).await?;
sqlx::query("DELETE FROM attendances")
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM notes").execute(&mut *tx).await?;
sqlx::query("DELETE FROM slots").execute(&mut *tx).await?;
sqlx::query("DELETE FROM sessions").execute(&mut *tx).await?;
sqlx::query("DELETE FROM tutor_courses").execute(&mut *tx).await?;
sqlx::query("DELETE FROM students").execute(&mut *tx).await?;
sqlx::query("DELETE FROM sessions")
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM tutor_courses")
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM students")
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM rooms").execute(&mut *tx).await?;
sqlx::query("DELETE FROM tutors").execute(&mut *tx).await?;
sqlx::query("DELETE FROM courses").execute(&mut *tx).await?;
@@ -34,6 +42,6 @@ async fn reset(State(pool): State<SqlitePool>) -> Result<StatusCode, AppError> {
Ok(StatusCode::NO_CONTENT)
}
pub fn router() -> Router<SqlitePool> {
pub fn router() -> Router<AppState> {
Router::new().route("/__test__/reset", post(reset))
}

View File

@@ -1,11 +1,16 @@
use crate::{
AppState,
auth::TutorClaims,
error::AppError,
models::{CreateTutor, Tutor},
};
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::{get, post, delete},
Json, Router,
routing::{delete, get},
};
use sqlx::SqlitePool;
use crate::{auth::TutorClaims, error::AppError, models::{CreateTutor, Tutor}};
async fn list_tutors(
claims: TutorClaims,
@@ -16,7 +21,7 @@ async fn list_tutors(
}
let tutors = sqlx::query_as::<_, Tutor>(
"SELECT id, name, email, is_superadmin FROM tutors ORDER BY name"
"SELECT id, name, email, is_superadmin FROM tutors ORDER BY name",
)
.fetch_all(&pool)
.await?;
@@ -33,10 +38,19 @@ async fn create_tutor(
return Err(AppError::Unauthorized);
}
let hash = bcrypt::hash(&req.password, 12).map_err(|_| AppError::Unauthorized)?;
let salt = argon2::password_hash::SaltString::generate(&mut rand::thread_rng());
let argon2 = argon2::Argon2::default();
use argon2::password_hash::PasswordHasher;
let hash = argon2
.hash_password(req.password.as_bytes(), &salt)
.map_err(|e| {
tracing::error!(error = %e, "argon2 hash failed");
AppError::Unauthorized
})?
.to_string();
let id = sqlx::query(
"INSERT INTO tutors (name, email, password_hash, is_superadmin) VALUES (?, ?, ?, ?)"
"INSERT INTO tutors (name, email, password_hash, is_superadmin) VALUES (?, ?, ?, ?)",
)
.bind(&req.name)
.bind(&req.email)
@@ -79,7 +93,7 @@ async fn delete_tutor(
Ok(StatusCode::NO_CONTENT)
}
pub fn router() -> Router<SqlitePool> {
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/admin/tutors", get(list_tutors).post(create_tutor))
.route("/api/admin/tutors/{id}", delete(delete_tutor))

View File

@@ -1,49 +1,92 @@
// cfg(test) only — this whole module is test-only
use sqlx::SqlitePool;
use crate::AppState;
use axum::Router;
use tower::ServiceExt;
use axum::http::{Request, StatusCode};
use axum::http::{HeaderMap, Request, StatusCode};
use http_body_util::BodyExt;
use sqlx::SqlitePool;
use tower::ServiceExt;
pub const TEST_SECRET: &str = "testsecret";
/// Insert a test tutor (if not exists), return a valid JWT for that tutor.
pub async fn make_token(pool: &SqlitePool, email: &str, is_superadmin: bool) -> String {
pub async fn make_token(
pool: &SqlitePool,
email: &str,
is_superadmin: bool,
secret: &str,
) -> String {
let hash = bcrypt::hash("testpass", 4).unwrap();
sqlx::query("INSERT OR IGNORE INTO tutors (name,email,password_hash,is_superadmin) VALUES (?,?,?,?)")
.bind("Test Tutor").bind(email).bind(&hash).bind(is_superadmin)
.execute(pool).await.unwrap();
sqlx::query(
"INSERT OR IGNORE INTO tutors (name,email,password_hash,is_superadmin) VALUES (?,?,?,?)",
)
.bind("Test Tutor")
.bind(email)
.bind(&hash)
.bind(is_superadmin)
.execute(pool)
.await
.unwrap();
// Ensure the superadmin flag is correct even if it existed
sqlx::query("UPDATE tutors SET is_superadmin = ? WHERE email = ?")
.bind(is_superadmin).bind(email).execute(pool).await.unwrap();
.bind(is_superadmin)
.bind(email)
.execute(pool)
.await
.unwrap();
let row: (i64, bool) = sqlx::query_as("SELECT id, is_superadmin FROM tutors WHERE email = ?")
.bind(email).fetch_one(pool).await.unwrap();
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
crate::auth::encode_jwt(row.0, email, row.1).unwrap()
.bind(email)
.fetch_one(pool)
.await
.unwrap();
crate::auth::encode_jwt(row.0, row.1, secret, false).unwrap()
}
/// Build the full Axum app wired with the given pool, plus a Bearer auth header value.
pub async fn build_test_app(pool: SqlitePool) -> (Router, String) {
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
let token = make_token(&pool, "tutor@test.com", false).await;
let app = crate::routes::build(pool, false);
let token = make_token(&pool, "tutor@test.com", false, TEST_SECRET).await;
let state = AppState {
pool: pool.clone(),
jwt_secret: TEST_SECRET.into(),
test_mode: true,
};
let app = crate::routes::build(state, true);
(app, format!("Bearer {token}"))
}
/// Build the full Axum app wired with a superadmin Bearer auth header.
pub async fn build_test_admin_app(pool: SqlitePool) -> (Router, String) {
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
let token = make_token(&pool, "admin@test.com", true).await;
let app = crate::routes::build(pool, false);
let token = make_token(&pool, "admin@test.com", true, TEST_SECRET).await;
let state = AppState {
pool: pool.clone(),
jwt_secret: TEST_SECRET.into(),
test_mode: true,
};
let app = crate::routes::build(state, true);
(app, format!("Bearer {token}"))
}
/// POST JSON body to the app (one-shot), returns (StatusCode, response body bytes).
pub async fn post_json(app: Router, path: &str, auth: &str, body: serde_json::Value)
-> (StatusCode, bytes::Bytes)
{
pub async fn post_json(
app: Router,
path: &str,
auth: &str,
body: serde_json::Value,
) -> (StatusCode, bytes::Bytes) {
let (status, body, _) = post_json_with_headers(app, path, auth, body).await;
(status, body)
}
pub async fn post_json_with_headers(
app: Router,
path: &str,
auth: &str,
body: serde_json::Value,
) -> (StatusCode, bytes::Bytes, HeaderMap) {
let mut builder = Request::builder()
.method("POST").uri(path)
.method("POST")
.uri(path)
.header("Content-Type", "application/json");
if !auth.is_empty() {
builder = builder.header("Authorization", auth);
@@ -53,22 +96,26 @@ pub async fn post_json(app: Router, path: &str, auth: &str, body: serde_json::Va
.unwrap();
let res = app.oneshot(req).await.unwrap();
let status = res.status();
let headers = res.headers().clone();
let body = res.into_body().collect().await.unwrap().to_bytes();
(status, body)
(status, body, headers)
}
/// PUT JSON body to the app (one-shot), returns (StatusCode, response body bytes).
pub async fn put_json(app: Router, uri: &str, auth: &str, body: serde_json::Value)
-> (StatusCode, bytes::Bytes)
{
let mut req = Request::builder()
pub async fn put_json(
app: Router,
uri: &str,
auth: &str,
body: serde_json::Value,
) -> (StatusCode, bytes::Bytes) {
let mut builder = Request::builder()
.method("PUT")
.uri(uri)
.header("Content-Type", "application/json");
if !auth.is_empty() {
req = req.header("Authorization", auth);
builder = builder.header("Authorization", auth);
}
let req = req
let req = builder
.body(axum::body::Body::from(body.to_string()))
.unwrap();
let res = app.oneshot(req).await.unwrap();
@@ -84,14 +131,14 @@ pub async fn patch_json(
auth: &str,
body: serde_json::Value,
) -> (StatusCode, bytes::Bytes) {
let mut req = Request::builder()
let mut builder = Request::builder()
.method("PATCH")
.uri(uri)
.header("Content-Type", "application/json");
if !auth.is_empty() {
req = req.header("Authorization", auth);
builder = builder.header("Authorization", auth);
}
let req = req
let req = builder
.body(axum::body::Body::from(body.to_string()))
.unwrap();
let res = app.oneshot(req).await.unwrap();
@@ -102,14 +149,11 @@ pub async fn patch_json(
/// GET from the app (one-shot), returns (StatusCode, response body bytes).
pub async fn get(app: Router, path: &str, auth: &str) -> (StatusCode, bytes::Bytes) {
let mut builder = Request::builder()
.method("GET").uri(path);
let mut builder = Request::builder().method("GET").uri(path);
if !auth.is_empty() {
builder = builder.header("Authorization", auth);
}
let req = builder
.body(axum::body::Body::empty())
.unwrap();
let req = builder.body(axum::body::Body::empty()).unwrap();
let res = app.oneshot(req).await.unwrap();
let status = res.status();
let body = res.into_body().collect().await.unwrap().to_bytes();

View File

@@ -1,69 +1,90 @@
# Implementation Plan: Room Editor Refactor (Core & Logic)
**Objective:** Standardize the room layout data model, align backend/frontend types, and refactor the core editor logic for robustness and grid-based precision.
**Objective:** Fix the pixel vs. grid-unit mismatch in stored room data, and robustify the editor for professional room planning.
**Background:**
The current room implementation suffers from naming inconsistencies (`type` vs `kind`) and coordinate system mismatches (pixels vs grid units). The editor logic in `RoomCanvas.svelte` is basic and needs to be more robust to support professional room planning.
**Background:**
The editor (`RoomCanvas.svelte`) already stores and renders in grid units (1 unit = 40 px). However, the demo seed (`demo_seed.sql`) was written with raw pixel values (e.g. `width: 200`), causing demo Room A to render broken (200 grid units = 8000 px). Any room created via the editor since launch is correct; any room predating the editor's grid-unit switch (or the demo room) is broken. A one-time data migration is the first priority.
**Note on the `type`/`kind` field:** `backend/src/models.rs:81` already bridges this with `#[serde(rename = "type")] pub kind: String`. The wire format is `type` and `frontend/src/lib/types.ts:33` already uses `type`. **No rename is needed.** If the Rust internal name is ever changed to `type`, a raw identifier (`r#type`) is required since `type` is reserved.
**Note on backend validation:** `backend/src/routes/rooms.rs:1869` already implements `validate_layout` with empty-layout check, unique IDs, allowed types (`seat`, `table`, `gap`, `door`), unique seat labels, and non-negative geometry. Tests at lines 184322 cover all of it. **Task 2 below replaces the previously planned duplicate work.**
**Note on `SeatMap.svelte`:** This plan does **not** touch `SeatMap.svelte`. Its retirement and replacement with a dynamic renderer is handled by the sibling visualization plan. Any `LayoutElement` contract change made here must be cross-checked against `backend/src/routes/checkin.rs:53,194` (which deserialises it) and `frontend/src/lib/types.ts:86` (`CheckinInfo.layout`).
---
## 1. Data Model & Type Alignment
## 1. Data Migration & Seed Fix
### Task 1: Standardize LayoutElement Naming
### Task 1: Pixel → Grid-Unit Migration
**Files to Modify:**
- `backend/src/models.rs`
- `frontend/src/lib/types.ts`
- `backend/migrations/003_normalize_room_layout_units.sql` *(create)*
- `backend/demo/demo_seed.sql`
- `backend/src/routes/rooms.rs` (update tests that assert large numeric coordinates)
**Changes:**
- Unified field name `type` (using `#[serde(rename = "type")]` if necessary in Rust, or changing it consistently).
- Standardize coordinate units: All `x`, `y`, `width`, `height` values in the database will represent **grid units** (e.g., 1 unit = 40px) rather than raw pixels.
- Update `demo_seed.sql` to use these normalized grid units.
- Write `003_normalize_room_layout_units.sql`. For each row in `rooms`, parse `layout_json`; if any element has `x`, `y`, `width`, or `height` > 50, divide all four by 40 and update the row. This heuristic is safe because grid-unit values are small integers/half-steps (max ~30), while pixel values are large (typically 80800).
- Update `demo_seed.sql:1641` to use grid units (e.g. `width: 200``width: 5`). The 24 elements in demo Room A need to be re-measured in grid units.
- Update any integration tests in `rooms.rs` that rely on large pixel-scale layout values.
### Task 2: Backend Validation
### Task 2: Backend Validation (Scope Reduction)
**Files to Modify:**
- `backend/src/routes/rooms.rs`
**Changes:**
- Add validation logic to `POST /api/admin/rooms` and `PUT /api/admin/rooms/:id/layout`.
- Ensure all elements have unique IDs.
- Validate that `type` is one of the allowed strings (`seat`, `table`, `door`, `gap`).
**Changes (additive only — do not duplicate existing logic):**
- Add upper-bound validation: `x` and `y` must be < a `MAX_CANVAS` constant (e.g. 100 grid units). Reject elements that fall off the canvas.
- Add grid-step validation: `x`, `y`, `width`, `height` must be multiples of 0.5 (i.e. `(value * 2) % 1 == 0`). Apply post-migration so existing data has already been normalised.
- Add a test for each new validator.
---
## 2. Editor Core Refactor
### Task 3: RoomCanvas Logic Overhaul
### Task 3: RoomCanvas State & Behaviour
**Files to Modify:**
- `frontend/src/lib/RoomCanvas.svelte`
**Current state (188 lines):**
- Drag: `draggingId / startX / startY` only (lines 2628). No resize state or handles exist.
- Snap: lines 4748 snap to 0.25 grid units (`Math.round(.../10)*10/40`). This is partially correct but the increment should be configurable (0.5 default).
- Rendering: already multiplies by `GRID_SIZE = 40` (line 85). Unit separation is mostly correct.
- Bug: `onmousemove` / `onmouseup` are bound on the SVG only (lines 6971). Releasing the cursor outside the SVG strands the drag. Move these listeners to `window` for the duration of a drag.
**Changes:**
- **Grid Snap:** Implement mandatory snap-to-grid (0.5 or 1.0 unit increments) during dragging and resizing.
- **State Management:** Refactor internal dragging state to be cleaner and more predictable.
- **Selection:** Improve the selection highlight and event propagation.
- **Unit Separation:** Ensure the component strictly thinks in grid units, with the rendering layer handling the pixel scaling.
- **Build resize from scratch.** Add resize handles (e.g. bottom-right corner hit area) per element. Track `resizingId`, `resizeStartX`, `resizeStartY`, `resizeStartW`, `resizeStartH` as drag state. Snap resize delta to 0.5 increments.
- **Fix drag escape.** Bind `mousemove`/`mouseup` to `window` when dragging begins; remove them on drop.
- **Snap increment.** Change snap to 0.5 grid units (from 0.25). Accept an optional `snapStep` prop (default `0.5`) for the snap-toggle feature below.
### Task 4: Editor UI Improvements
**Files to Modify:**
- `frontend/src/routes/admin/rooms/[roomId]/+page.svelte`
**Changes:**
- Add a "Snap to Grid" toggle.
- Add numeric inputs for precise coordinate editing (X, Y, W, H).
- Implement "duplicate element" functionality.
- Better error handling and visual feedback during saving.
**What already exists (do not re-add):**
- Width/height inputs with `step="0.5"` (lines 9097)
- Label input (line 87)
- Add seat/table/door buttons (lines 6466)
- Delete button (line 101)
**What to add:**
- **X/Y numeric inputs** (with `step="0.5"`) for precise coordinate editing of the selected element, bound to its `x` and `y` fields.
- **"+ Gap" button** alongside the existing add buttons. `gap` is accepted by `validate_layout` but is currently unreachable from the UI.
- **"Snap to Grid" toggle.** Bind to a boolean state; pass as `snapStep={snapEnabled ? 0.5 : 0}` to `RoomCanvas`.
- **"Duplicate element" button.** Copies the selected element with a new UUID and offsets it by 1 grid unit.
- **Surface save errors.** `saveLayout` (lines 2729) currently only `console.error`. Display an inline error message in the UI.
---
## 3. Verification
### Automated Tests:
- `backend/src/routes/rooms.rs`: Add unit tests for layout validation.
- `frontend/tests/rooms.spec.ts`: Create a new Playwright test for room editing (creating elements, dragging, snapping, and saving).
- `backend/src/routes/rooms.rs`: Tests for the new upper-bound and grid-step validators.
- `backend/migrations/`: Verify migration 003 runs cleanly on the test DB (use `sqlx migrate run`).
- `frontend/tests/rooms.spec.ts` *(new)*: Playwright test — create a room, add table and two seats via the UI, drag a seat (verify snap), save and reload, assert coordinates are preserved.
### Manual Verification:
1. Create a new room.
2. Add a table and two seats.
3. Verify that dragging snaps to the grid.
4. Save and reload to ensure coordinates are preserved exactly.
5. Inspect the SQLite database to confirm coordinates are stored as small grid units (e.g., `2.5`) instead of large pixel values.
1. `make seed-demo` — reseed with the fixed `demo_seed.sql`.
2. Open `Admin → Rooms → Room A` in the editor. All elements must appear at sensible grid positions (not far off-screen).
3. Drag an element: verify it snaps to 0.5-unit increments.
4. Resize an element: verify handles appear and snap correctly.
5. Add a Gap element and verify it can be placed and saved.
6. Inspect the SQLite DB directly: `SELECT layout_json FROM rooms LIMIT 1`. All element coordinates must be small numbers (≤ 30), not pixel values (≥ 80).
7. Save and reload: verify coordinates are exactly preserved (no rounding drift).

View File

@@ -1,27 +1,46 @@
# Implementation Plan: Room Editor Refactor (Unified Visualization)
**Objective:** Replace hardcoded seat maps with a unified, dynamic, and high-fidelity room visualization system that works across Admin and Student views.
**Background:**
Currently, the application uses a hardcoded `SeatMap.svelte` for Live Views and Student Check-ins, while using a dynamic `RoomCanvas.svelte` for editing. This leads to data mismatches and prevents users from using custom room layouts. This plan unifies the visualization layer.
**Objective:** Replace the broken hardcoded `SeatMap.svelte` with a unified, dynamic room renderer that works across Admin Live View and Student Check-in.
---
## 1. Unified Visualization Component
## Pre-flight: Existing Bugs This Work Fixes
### Task 1: Create `DynamicRoomView.svelte`
**Files to Create/Modify:**
- `frontend/src/lib/components/DynamicRoomView.svelte`
- (Optionally) Merge into `frontend/src/lib/RoomCanvas.svelte`
Acknowledge these before starting — do not assume current behaviour is correct.
1. **Student check-in seat selection is silently broken in production.** `frontend/src/lib/components/SeatMap.svelte:3358` uses hardcoded seat IDs (`T1-1``T4-5`). These IDs do not exist in any room's `layout_json`, so `POST /api/checkin` is rejected by `backend/src/routes/checkin.rs:200207` (`"invalid seat"`) for every seat click. Migrating to the dynamic renderer is the fix.
2. **Admin live view shows no occupancy data.** `frontend/src/routes/admin/live/[slotId]/+page.svelte:161` calls `<SeatMap variant="tutor" scale={0.78} />` with no `assignments` / `students` props — so even though `attendances` is loaded, nothing renders in the seat map.
3. **Check-in response is mistyped on the frontend.** `s/[code]/+page.svelte:82` treats the response of `POST /api/checkin` as an `Attendance` object, but the backend returns `{"ok": true}` (`checkin.rs:159281`). Currently masked because `loadInfo()` is re-called immediately after. Fix this type error while migrating.
**Note on other consumers of `SeatMap`:** A grep of `frontend/src/` found `SeatMap` is used only in the two routes described below. There are no dashboard, print-view, or mobile-only consumers.
---
## 1. Extend `RoomCanvas` (Decision: Don't Fork)
### Task 1: Add Read-Only / Interactive Modes to `RoomCanvas.svelte`
**Files to Modify:**
- `frontend/src/lib/RoomCanvas.svelte`
**Decision rationale:** `RoomCanvas.svelte:1424` already accepts `occupiedSeatIds`, `mySeatId`, `studentNames`, `selectedId`, `onElementClick`, and `editable`. Creating a separate `DynamicRoomView.svelte` would duplicate the SVG/element rendering logic and risk divergence. **Extend `RoomCanvas` instead.**
**Changes:**
- Create a read-only/interactive component that renders SVG layouts based on the `LayoutElement[]` data.
- **High Fidelity:** Implement the aesthetic details from the design handoff (rounded tables, specific seat styling, label positioning).
- **Responsive Scaling:** Implement an `autoScale` or `viewBox` based system so the room fills the available width on mobile and desktop without breaking coordinates.
- **Interaction Modes:**
- `mode="checkin"`: Seats are clickable for students.
- `mode="notes"`: Seats are clickable for tutors to open note editors.
- `mode="display"`: Read-only view for dashboard/monitoring.
- Add a `clickable: boolean` prop (default `false`) — enables `onElementClick` in read-only mode without enabling edit handles. This maps to the `checkin` and `notes` use cases.
- Remove the hardcoded `width="800" height="600"` (line 65). Replace with a `viewBox` computed from element extents (or a configured canvas size), `preserveAspectRatio="xMidYMid meet"`, and a CSS `width: 100%; height: auto` so the SVG scales responsively to its container. Verify this does not break the editor (pass a fixed `style="width:800px"` wrapper in the editor route).
- Add high-fidelity styling per the design handoff: rounded tables (`rx`/`ry` on rect), specific seat styling (circle or rounded-rect), label positioning (centred on table, below seat icon), seat-state colours (vacant / occupied / mine).
**Prop summary after changes:**
| Prop | Type | Purpose |
|---|---|---|
| `elements` | `LayoutElement[]` | Layout data |
| `editable` | `boolean` | Enables drag, resize, add, delete |
| `clickable` | `boolean` | Enables `onElementClick` in read-only mode |
| `occupiedSeatIds` | `string[]` | Seats to style as occupied |
| `mySeatId` | `string \| null` | Seat to style as "mine" |
| `studentNames` | `Record<string,string>` | Labels overlaid on occupied seats |
| `selectedId` | `string \| null` | Currently selected element (editor) |
| `onElementClick` | `(id: string) => void` | Click callback |
---
@@ -30,37 +49,67 @@ Currently, the application uses a hardcoded `SeatMap.svelte` for Live Views and
### Task 2: Replace `SeatMap` in Admin Live View
**Files to Modify:**
- `frontend/src/routes/admin/live/[slotId]/+page.svelte`
- `backend/src/routes/attendance.rs` *(extend API response)*
**Changes:**
- Replace `SeatMap` with `DynamicRoomView`.
- Connect the `onSeatClick` event to the note-taking and manual attendance logic.
- Ensure attendance data (who sits where) is correctly overlaid on the dynamic layout.
**Pre-step — extend the backend API response (required):**
`attendance.rs:62105` (`get_session_attendance`) returns `SessionAttendance` but does not include room layouts. The page has no way to know the layout. Options:
- **(Recommended)** Extend `SessionAttendance` in `backend/src/models.rs` and `attendance.rs` to include each slot's `layout: Option<Vec<LayoutElement>>` keyed per slot. This avoids an N+1 fetch.
- Alternative: have the page call `api.admin.rooms.get(slot.room_id)` after loading the slot. Simpler but adds a round-trip.
**Frontend changes:**
- Replace `<SeatMap variant="tutor" scale={0.78} />` at line 161 with `<RoomCanvas elements={slot.layout ?? []} clickable={true} occupiedSeatIds={...} studentNames={...} onElementClick={handleSeatClick} />`.
- `occupiedSeatIds`: derive from `attendances.map(a => a.seat_id).filter(Boolean)`.
- `studentNames`: derive from `attendances` as `{ [seat_id]: student.name }`.
- **Seat → student mapping (new logic required):** The existing note-editor is driven by `selectedStudentId` and `toggleAttendance` takes `studentId`. The new `onElementClick(seatId)` must look up `attendances.find(a => a.seat_id === seatId)?.student_id` to populate `selectedStudentId`. Add this mapping in `handleSeatClick`.
### Task 3: Replace `SeatMap` in Student Check-in
**Files to Modify:**
- `frontend/src/routes/s/[code]/+page.svelte`
**Changes:**
- Replace `SeatMap` with `DynamicRoomView`.
- Connect seat selection to the `POST /api/checkin` API.
- Ensure the "current seat" (mySeatId) is visually highlighted in the dynamic view.
**There are 4 call sites, not 1.** `SeatMap` is called at lines 210, 248, 316, and 368 (phone + desktop × seat-pick step + confirmed step). All four need to be replaced.
**Layout data is already on the wire.** `GET /api/checkin/:code` returns `CheckinInfo.layout: LayoutElement[] | null` (populated by `checkin.rs:5368` and typed in `types.ts:8488`), but `s/[code]/+page.svelte:4358` discards `res.layout`. Read and store it: `let layout = $state<LayoutElement[]>([])` and assign `layout = res.layout ?? []`.
**Per call site:**
- Lines 210, 316 (seat-pick step, phone and desktop): Replace with `<RoomCanvas elements={layout} clickable={true} occupiedSeatIds={occupiedSeatIds} mySeatId={null} onElementClick={selectSeat} />`.
- Lines 248, 368 (confirmed step, phone and desktop): Replace with `<RoomCanvas elements={layout} clickable={false} occupiedSeatIds={occupiedSeatIds} mySeatId={myAttendance?.seat_id ?? null} />`.
**Derived state to add:**
```ts
const occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(Boolean) as string[]);
```
**Fix the response-typing bug** (Pre-flight item 3): `api.ts` types `checkin.post` as `Promise<Attendance>` but the backend returns `{ok: true}`. Change the return type to `Promise<{ok: boolean}>` and update `s/[code]/+page.svelte:82` accordingly.
### Task 4: Deprecate `SeatMap.svelte`
**Files to Modify:**
- Delete `frontend/src/lib/components/SeatMap.svelte` once integration is verified.
**Files to Delete:**
- `frontend/src/lib/components/SeatMap.svelte`
**Hard ordering — do not delete until all of the following are true:**
1. All 6 call sites are migrated (1 in admin live view + 4 in `s/[code]`).
2. `grep -rn "SeatMap" frontend/src/` returns zero results.
3. All Playwright tests pass (see Task 5).
---
## 3. Verification
### Automated Tests:
- `frontend/tests/checkin-dynamic.spec.ts`: E2E test to verify student check-in on a **custom-created** room layout.
- `frontend/tests/admin-live-dynamic.spec.ts`: E2E test to verify that tutors can see students on a **custom-created** room layout and click them to leave notes.
### Recommended Ordering
1. Extend the backend API (Task 2 pre-step) — unblocks frontend.
2. Extend `RoomCanvas` with `clickable`, responsive `viewBox`, and high-fidelity styling (Task 1).
3. Migrate `s/[code]` (Task 3) — backend already returns layout; this is the quickest win and immediately unblocks the broken check-in.
4. Migrate admin live view (Task 2) — needs new backend data and the seat→student mapping.
5. Run Playwright tests.
6. Delete `SeatMap.svelte` (Task 4).
### Manual Verification:
1. Create a non-standard room layout in the Admin Editor (e.g., a "U" shape).
### Automated Tests
- `frontend/tests/checkin-dynamic.spec.ts` *(new)*: E2E test — create a custom (non-square) room layout via the API, create a session + slot using it, open the student check-in link, verify the layout renders (not a blank grid), click a seat, verify `POST /api/checkin` succeeds and the seat turns green. Mirror the seat IDs already used in `backend/src/routes/checkin.rs:290653` (`s1`, `s2`).
- `frontend/tests/admin-live-dynamic.spec.ts` *(new)*: E2E test — using the same custom room, manually add attendance for a student on seat `s1`, open the tutor live view, verify the student's name appears on the correct seat.
### Manual Verification
1. Create a U-shaped room layout in `Admin → Rooms`.
2. Create a session and slot using this room.
3. Open the Student Check-in link on a mobile device (browser simulation).
4. Verify the "U" shape is rendered correctly and scaled to fit the screen.
5. Check in as a student and verify the seat turns green.
6. Open the Tutor Live View on a desktop and verify the same student is visible on the same seat in the "U" shape.
3. Open the student check-in link on a mobile viewport. Verify the U-shape is rendered and scaled to fit.
4. Check in as a student by clicking a seat. Verify the seat turns green.
5. Open the tutor Live View on desktop. Verify the same student appears on the correct seat in the U-shape.
6. Click the occupied seat. Verify the note-editor opens for that student.

View File

@@ -7,6 +7,7 @@ metadata:
{{- include "tutortool.labels" . | nindent 4 }}
spec:
schedule: "0 3 * * *"
successfulJobsHistoryLimit: 1
jobTemplate:
spec:
template:

View File

@@ -7,6 +7,8 @@ metadata:
{{- include "tutortool.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: {{ .Values.strategy.type | default "Recreate" }}
selector:
matchLabels:
{{- include "tutortool.selectorLabels" . | nindent 6 }}
@@ -16,6 +18,10 @@ spec:
{{- include "tutortool.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "tutortool.serviceAccountName" . }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
fsGroup: 1000
containers:
@@ -29,6 +35,10 @@ spec:
value: {{ .Values.env.DATABASE_URL | quote }}
- name: STATIC_DIR
value: {{ .Values.env.STATIC_DIR | quote }}
{{- range $k, $v := .Values.env.extra }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
- name: JWT_SECRET
valueFrom:
secretKeyRef:
@@ -50,8 +60,10 @@ spec:
initialDelaySeconds: 5
periodSeconds: 10
securityContext:
readOnlyRootFilesystem: false
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:

View File

@@ -5,10 +5,17 @@ metadata:
namespace: {{ .Release.Namespace }}
labels:
{{- include "tutortool.labels" . | nindent 4 }}
{{- with .Values.httpRoute.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
parentRefs:
- name: itsh-gateway
sectionName: {{ .Values.httpRoute.sectionName }}
{{- range .Values.httpRoute.parentRefs }}
- name: {{ .name }}
namespace: {{ .namespace }}
sectionName: {{ $.Values.httpRoute.sectionName }}
{{- end }}
hostnames:
{{- range .Values.httpRoute.hostnames }}
- {{ . | quote }}
@@ -31,8 +38,11 @@ metadata:
{{- include "tutortool.labels" . | nindent 4 }}
spec:
parentRefs:
- name: itsh-gateway
sectionName: http-tutor-puchstein-dev
{{- range .Values.httpRoute.parentRefs }}
- name: {{ .name }}
namespace: {{ .namespace }}
sectionName: {{ $.Values.httpRoute.httpRedirectSectionName }}
{{- end }}
hostnames:
{{- range .Values.httpRoute.hostnames }}
- {{ . | quote }}

View File

@@ -1,5 +1,8 @@
replicaCount: 1
strategy:
type: Recreate
image:
repository: registry.itsh.dev/s0wlz/tutortool
pullPolicy: IfNotPresent
@@ -20,17 +23,26 @@ resources:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
cpu: 200m
memory: 256Mi
pvc:
storageClassName: hcloud-volumes
storage: 1Gi
imagePullSecrets:
- name: itsh-registry
httpRoute:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
parentRefs:
- name: default
namespace: nginx-gateway
hostnames:
- tutor.puchstein.dev
sectionName: https-tutor-puchstein-dev
httpRedirectSectionName: http
# JWT_SECRET provisioned as a pre-existing K8s Secret named here.
# Do not set jwtSecretValue in committed values — provision via kubectl manually.
@@ -39,6 +51,9 @@ jwtSecretName: tutortool-jwt
env:
DATABASE_URL: sqlite:/data/attendance.db
STATIC_DIR: /app/frontend/build
extra: {}
# extra:
# DEMO: "true" # seeds demo data on startup (idempotent, INSERT OR IGNORE)
vpa:
enabled: false

View File

@@ -3,4 +3,7 @@ httpRoute:
- tutor.puchstein.dev
image:
tag: latest
tag: v0.1.15
env:
extra: {}

View File

@@ -8,6 +8,6 @@ services:
environment:
- DATABASE_URL=sqlite:/data/attendance.db
- STATIC_DIR=/app/frontend/build
- JWT_SECRET=${JWT_SECRET:-dev_secret_for_demo}
- JWT_SECRET=${JWT_SECRET}
- PORT=3000
restart: always

View File

@@ -1,5 +1,7 @@
# Attendance Tracking Tool Implementation Plan
> **STATUS:** ✅ All tasks completed. The project has been hardened and modernized as of 2026-05-02.
>
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a self-hosted web app (Rust/Axum + SvelteKit + SQLite) where students self-check-in to weekly tutoring sessions via a projected URL, and tutors manage sessions, attendance, and per-student notes.

View File

@@ -15,14 +15,14 @@ The tool is designed to be reusable across future semesters and other tutorien.
### Stack
- **Backend:** Rust + Axum, `sqlx` (SQLite), JWT auth for tutors
- **Frontend:** SvelteKit with `adapter-static` (SPA, served by Axum)
- **Backend:** Rust + Axum (0.8), `sqlx` (SQLite), Secure httpOnly Cookie JWT auth for tutors
- **Frontend:** SvelteKit 5 (Svelte runes), TypeScript, `adapter-static` (SPA, served by Axum)
- **Database:** SQLite via a Kubernetes PersistentVolumeClaim
- **Deployment:** Single container on a k8s namespace, `tutor.puchstein.dev`
Single binary + static files, one container, one PVC. No Node server at runtime — minimizes node load. SQLite keeps the footprint small and makes end-of-semester teardown trivial.
Axum must serve `index.html` as fallback for all non-`/api` routes so that SvelteKit client-side routing works on direct navigation or page refresh.
Axum serves the static frontend and caches the `JWT_SECRET` in `AppState` for efficient session validation. It also serves `index.html` as fallback for all non-`/api` routes so that SvelteKit client-side routing works on direct navigation or page refresh.
### Repository layout
@@ -30,27 +30,26 @@ Axum must serve `index.html` as fallback for all non-`/api` routes so that Svelt
tools/attendance/
├── backend/ # Rust/Axum
│ ├── src/
│ │ ├── main.rs
│ │ ├── main.rs # entry point, AppState definition
│ │ ├── db.rs # sqlx pool setup, migrations
│ │ ├── routes/
│ │ │ ├── admin.rs # tutor-facing endpoints
│ │ │ ├── mod.rs # router assembly
│ │ │ ├── auth_routes.rs # Secure cookie-based login/logout
│ │ │ ├── checkin.rs # student-facing endpoints
│ │ │ └── export.rs # CSV, Markdown, SQLite backup
│ │ ── auth.rs # JWT middleware
│ │ ── auth.rs # JWT logic, cookie extractor
│ │ └── models.rs # shared data models
│ └── Cargo.toml
├── frontend/ # SvelteKit
├── frontend/ # SvelteKit 5
│ ├── src/
│ │ ├── routes/
│ │ │ ├── admin/ # tutor panel
│ │ │ └── s/[code]/ # student check-in
│ │ │ ├── admin/ # tutor panel (protected)
│ │ │ └── s/[code]/ # student check-in (public)
│ │ └── lib/
│ │ ├── auth.svelte.ts # runes-based auth state
│ │ └── api.ts # cookie-based API client
│ └── svelte.config.js # adapter-static
└── k8s/
├── deployment.yaml
├── service.yaml
├── ingress.yaml
├── pvc.yaml
└── cronjob.yaml # daily SQLite backup, retains last 7
└── deploy/ # Helm chart
```
Visual/frontend design is handled separately via Claude Design — this spec covers structure and flows only.
@@ -159,7 +158,7 @@ CREATE TABLE notes (
- `slots.code` and `slots.status` must be set atomically in a single `UPDATE slots SET status = 'open', code = ? WHERE id = ?`. The backend refuses to serve a check-in page for any slot where `status = 'open'` but `code IS NULL`.
- For layout-bearing slots (`room_id IS NOT NULL`), the backend rejects `seat_id = NULL` submissions at the application layer — the DB NULL-deduplication behaviour cannot enforce this.
- **Seat change:** A student may change their seat while the slot is `open`. The backend deletes the existing attendance row for `(slot_id, student_id)` then inserts the new one atomically in a transaction. The previously held seat becomes free immediately. Once the slot is `locked`, no changes are possible.
- **Cookie trust:** Cookies are unsigned in the initial implementation — accepted risk for a small in-person group where the tutor physically observes the room. Tutor must cross-check the seat map against visible students before locking. The `checkin.rs` auth layer is designed for a drop-in HMAC replacement without further changes.
- **Auth Security:** The authentication layer is hardened using `httpOnly`, `SameSite=Strict` cookies for both tutor and student sessions. This prevents client-side token access (XSS mitigation) and ensures session integrity. The `checkin.rs` layer manages student identities via a secure `attendance_identity` cookie.
Rooms are created independently of sessions and can be reused across semesters. The student dropdown on the check-in page is filtered by the slot's course, preventing cross-course name leakage.

View File

@@ -46,12 +46,12 @@ After `make test-up`:
1. Ask Claude to open `http://127.0.0.1:<TT_TEST_PORT>/admin/login` via Playwright MCP.
2. Log in with seed credentials: `admin@tutortool.com` / `admin`.
3. Drive the app interactively; take screenshots to verify UI.
3. Drive the app interactively; take screenshots to verify UI. (Note: Authentication is handled via secure `httpOnly` cookies).
4. Run `make test-reset` between scenarios to restore clean state.
## DB reset mechanism
The backend exposes `POST /__test__/reset` only when started with `TT_TEST_MODE=1`. The handler deletes all rows in FK-safe order and re-applies `backend/demo/demo_seed.sql` in a single transaction. It never exists in production (the route is not registered without the env flag).
The backend exposes `POST /__test__/reset` only when started with `TT_TEST_MODE=1` AND in debug builds. The handler deletes all rows in FK-safe order and re-applies `backend/demo/demo_seed.sql` in a single transaction. It never exists in production release builds.
## Seed data
@@ -66,11 +66,11 @@ The backend exposes `POST /__test__/reset` only when started with `TT_TEST_MODE=
## CI
The Gitea Actions workflow at `.gitea/workflows/test.yml` runs on every push to `main` and on PRs:
The Gitea Actions workflow at `.gitea/workflows/ci.yml` runs on every push to `main` and on PRs:
1. Install deps (Node 20 + pnpm + Rust 1.95)
1. Install deps (Node 22 + pnpm 9 + Rust 1.95)
2. Cache Cargo + pnpm store
3. `cargo check` + `pnpm check` (type checks)
3. `make lint` (Zero Warnings Policy: clippy, fmt, svelte-check)
4. `cargo test` (unit tests)
5. `pnpm build` (frontend build)
6. `make test-up` + `pnpm test:e2e` (E2E)

View File

@@ -0,0 +1,285 @@
# Security & Best-Practices Audit for `tutortool`
## Overview
- Full-stack app: Rust 1.95 backend with Axum 0.8, SQLx 0.8 (SQLite), JWT auth; SvelteKit 2 + Svelte 5 + TypeScript 5 + Vite 8 + pnpm 9 frontend.
- CI/CD: Gitea Actions-style workflows for CI (PRs/branches) and Release (tag push), including Rust checks, frontend checks, Playwright E2E, cargo-audit, Docker build, Helm deploy to Kubernetes.
- Overall: architecture is solid and modern; most obvious footguns are avoided, but there are some security and hardening issues plus a few best-practice gaps in both backend and frontend.
## Toolchain & Dependencies
### Backend (Rust)
- Toolchain:
- `edition = "2024"`, `rust-version = "1.95.0"` pinned in `backend/Cargo.toml` and CI (`dtolnay/rust-toolchain@master toolchain: '1.95.0'`).
- Core crates:
- `axum 0.8` (web framework, with `macros`, `multipart`).
- `axum-extra 0.10` (cookies, etc.).
- `tokio 1` with `full` feature.
- `sqlx 0.8` with `sqlite`, `runtime-tokio`, `macros`, `migrate`.
- `serde`/`serde_json`.
- `jsonwebtoken 10` (JWT handling, `rust_crypto` backend).
- `bcrypt 0.19` (password hashing).
- `tower-http 0.6` with `fs`, `cors`, `trace`.
- `tower_governor 0.6` (rate limiting).
- `chrono 0.4` with `serde`, `rand 0.9`, `thiserror 2`, `tracing 0.1`, `tracing-subscriber 0.3`.
- Dev-deps:
- `tower 0.5` (util), `http-body-util 0.1`, `bytes 1`, `temp-env 0.3`, `serial_test 3.1`.
- The combination (Axum + SQLx + jsonwebtoken) is a common pattern; community examples like Axium and blog posts promote similar stacks for high-performance, security-focused APIs.[^1][^2][^3]
### Frontend (SvelteKit + TS)
- Toolchain:
- SvelteKit 2 (`@sveltejs/kit ^2.59.0`), Svelte `^5.55.5`, Vite `^8.0.10`, TypeScript `^5`, `@typescript/native-preview ^7.0.0-dev`, pnpm 9.
- Playwright `@playwright/test ^1.59.1` for E2E; `svelte-check` for type+template checking.
- This matches current Svelte best-practices: Vite-based tooling, svelte-check, Playwright, TS everywhere.[^4][^5]
### CI/CD
- CI workflow (`.gitea/workflows/ci.yml`):
- Runs on push (non-main), and PRs.
- Steps:
- Node 22 + pnpm 9.
- Rust 1.95 + clippy + rustfmt.
- Cache Cargo and pnpm store.
- `pnpm --dir frontend install --frozen-lockfile`.
- `svelte-kit sync`, Playwright browser install.
- Backend: `cargo check`, `cargo clippy -D warnings`, `cargo fmt --check`, `cargo test`.
- Frontend: `tsgo --version` (from `@typescript/native-preview`) and `pnpm check`.
- `cargo audit` with ignore `RUSTSEC-2023-0071`.
- Frontend build.
- E2E tests via `make test-up` + `pnpm test:e2e`, then teardown with `make test-down`.
- Docker build (no push).
- Release workflow (`.gitea/workflows/release.yml`):
- Triggered on tag `v*.*.*`.
- Re-runs checks, tests, cargo-audit, build.
- Docker build+push to `registry.itsh.dev/s0wlz/tutortool` with `latest` and tag, login via secrets.
- Installs kubectl config from base64-encoded secret, sets up Helm 3.16, and runs `helm upgrade --install` into namespace `tenant-5` with `values_override.yaml` and `image.tag` from tag.
- This aligns with modern GitHub/Gitea workflows: pinned major versions for actions, caching, separate CI and release pipelines, and Helm-based K8s deployment.[^6]
## Backend Best-Practices Review
### Axum / App Setup
- `AppState` holds `SqlitePool`, `jwt_secret`, and `test_mode`, and implements `FromRef` for the pool, matching ergonomic Axum+SQLx patterns.[^2]
- Middleware:
- `TraceLayer::new_for_http()` is enabled to log requests; static assets served via `ServeDir` with SPA-style fallback to `index.html`.
- Rate limiting (tower_governor) appears configured in `routes::build` (not shown in excerpt but implied by dependency choice).
- Ports and bindings:
- Binds to `0.0.0.0:PORT` (default 3000) and serves over plain HTTP; this is expected behind a reverse proxy/ingress, but in prod TLS termination should happen at the edge.
**Findings & Suggestions**
- Add graceful shutdown: hook into `axum::serve` with a shutdown signal to support rolling updates and avoid dropping in-flight requests.[^7]
- Ensure `tower_governor` is applied to all state-changing routes (auth, check-in, etc.) to mitigate brute force; current routes module likely does this, but it is worth verifying per-route.[^1]
### Error Handling
- There is a dedicated `error.rs` and `AppError` type (pattern recommended by Axum guides): single error type, `?` operator, mapping to HTTP responses.[^7]
- This is aligned with modern Rust error-handling best practices: central error enum plus `thiserror` for derive and automatic conversions.[^7]
**Findings & Suggestions**
- Confirm that `AppError::Unauthorized` and other variants do not leak internals (e.g., raw SQLx errors) in HTTP responses, and that detailed error messages go only to logs (`tracing`). This is in line with OWASP guidance on not exposing sensitive error details.[^8]
### SQLx / Database Access
- `AppState` owns the `SqlitePool`, consistent with SQLx ergonomics for Axum.[^2]
- SQLx with `sqlite` feature uses libsqlite3 under the hood and introduces some unsafe, but SQLx forbids unsafe by default for other backends; that trade-off is known and accepted for SQLite.[^3]
**Findings & Suggestions**
- Use fully parameterized queries everywhere, avoiding dynamic string concatenation; this matches SQLx and OWASP recommendations to prevent injection.[^9][^2]
- Use migrations consistently (`sqlx::migrate!()`) in `db::init()` and ensure the CI includes a `sqlx migrate run --check` equivalent (offline) to prevent drift between schema and code.[^3]
### JWT Handling (Authentication)
- Claims structure:
- `TutorClaims { sub: i64, email: String, is_superadmin: bool, exp: u64 }`.
- Encoding:
- `encode_jwt` sets `exp` to now + 7 days, uses `Header::default()` (HS256) and `EncodingKey::from_secret(secret.as_bytes())`.
- Decoding:
- `decode_jwt` uses `DecodingKey::from_secret(secret.as_bytes())` and `Validation::default()`; errors map to `AppError::Unauthorized` and are traced at debug level.
- Extraction:
- Custom Axum extractor `FromRequestParts` for `TutorClaims`:
- Tries `CookieJar` for `"token"` first (HttpOnly cookie expected from server) and falls back to `Authorization: Bearer <token>` header.
- Uses `AppState::from_ref` to access `jwt_secret` and calls `decode_jwt`.
- This pattern (HttpOnly cookie + optional Bearer header) is consistent with modern JWT auth designs, where cookies mitigate XSS theft of tokens and headers support scripting and tools.[^10][^8]
**Findings (JWT)**
- `Validation::default()` only enforces expiration but uses default algorithm and leeway settings; explicitly setting `algorithms` and `validate_exp` is recommended to avoid alg downgrade issues and to be resilient to changes in defaults.[^11][^12]
- `exp` is 7 days; OWASP and many JWT security guides recommend short-lived access tokens (1560 minutes) with refresh tokens if you need long-lived sessions.[^8][^10]
- No audience (`aud`), issuer (`iss`), or other context claims are validated; for multi-tenant or multi-client deployments, those should be set and verified.[^8]
**Suggestions (JWT)**
- Configure `Validation` explicitly:
- Restrict algorithms (e.g., HS256 only) and disable `validate_nbf` if not used, but keep `validate_exp` on.[^12][^11]
- Optionally validate `iss` and `aud`.
- Consider split token model:
- Short-lived access token in memory; long-lived refresh token in HttpOnly cookie as recommended by modern JWT best-practice guides.[^10]
- Consider moving `email` out of the token or keeping only user id + roles; JWT best-practice docs recommend storing only non-sensitive, strictly necessary data.[^13]
### Password Hashing
- Uses `bcrypt` crate (0.19). Bcrypt is widely used and still acceptable, but many modern Rust security boilerplates (e.g. Axium) prefer Argon2id (memory-hard, OWASP recommended).[^1][^10]
**Findings & Suggestions**
- If passwords are currently hashed with bcrypt, consider migrating to Argon2id for new deployments and implementing lazy rehash on login to avoid immediate full migration.[^1]
- Ensure appropriate work factor/cost is configured (bcrypt default cost may be low for 2026 hardware; OWASP recommends tuning to ~250 ms per hash on your hardware).[^10]
### Token & Secrets Management
- `jwt_secret` is loaded from `JWT_SECRET` env var; app panics if missing (`expect("JWT_SECRET must be set")`).
- CI and Release use `cargo audit` and Docker with registry login; K8s kubeconfig is passed via base64 secret into `~/.kube/config`, and image registry credentials are from `REGISTRY_USER` and `REGISTRY_TOKEN` secrets.
**Findings & Suggestions**
- Ensure `JWT_SECRET` is strong (at least 256 bits of randomness) and rotated periodically, as recommended in JWT and OWASP guidelines.[^8][^10]
- Use kube secret and Helm values files for database credentials, SMTP, etc.; avoid ever committing real secrets (current repo appears clean of obvious `.env`/secrets, matching typical TS/Rust security guidance).[^9]
### Test Mode & Test Reset Endpoint
- In debug builds, `TT_TEST_MODE=1` enables test-only behavior:
- Loads `demo/demo_seed.sql` into `routes::test_reset::SEED_SQL`.
- Logs warning `TT_TEST_MODE active — /__test__/reset is enabled`.
- `routes::build` likely wires `/__test__/reset` route guarded by `test_mode`.
- CI E2E flow sets `TT_TEST_PORT_RANDOM=1` and uses `make test-up` to start backend; expected pattern is that test mode is enabled only in CI/dev.
**Findings & Suggestions**
- Confirm that `TT_TEST_MODE` is never set in production environments; the log warning is helpful, but run-time checks or fail-fast on `TT_TEST_MODE=1` in release builds would add extra safety.[^8]
- The test reset endpoint should be fully disabled or return 404 in production; given architecture, this is likely already the case but should be validated in routing code.[^7]
## Frontend Best-Practices Review
### SvelteKit / Vite / TS Tooling
- Scripts:
- `dev`, `build`, `preview` for Vite.
- `check` and `check:watch` using `svelte-check` with `tsconfig.json`.
- `test:e2e` and `test:e2e:ui` using Playwright.
- Config:
- `svelte.config.js` and `vite.config.ts` present; `playwright.config.ts` configured for tests.
- This aligns with Svelte docs: use svelte-check, Vite, and a linter for robustness.[^5][^4]
**Findings & Suggestions**
- Consider adding ESLint with TypeScript+Svelte plugin to catch additional issues that TypeScript itself cannot (e.g., potential XSS sinks, unused variables).[^4][^9]
- Ensure CSP and other security headers are set at the backend/Ingress level, especially for inline script blocking and stronger XSS mitigation, matching Svelte and TS security recommendations.[^5][^9]
### TypeScript Practices
- Uses `typescript ^5` and `@typescript/native-preview ^7.0.0-dev`, with CI step `tsgo --version && pnpm check`, indicating use of the new TS native compiler experiment.
- Modern TS security guidance emphasizes strict typing, avoiding `any`/unchecked casts, and runtime validation of external data.[^9]
**Findings & Suggestions**
- Ensure `tsconfig.json` has strict flags (`strict`, `noImplicitAny`, `noUncheckedIndexedAccess` where feasible) to align with TS security best practices.[^9]
- For input forms and API responses, combine TypeScript types with runtime validation (e.g. Zod) where user or external data is processed, as recommended in recent TS security articles.[^9]
### Frontend Auth & Token Storage
- The backend issues JWTs intended to be stored primarily in HttpOnly `token` cookie and optionally in `Authorization` header.
- Modern JWT security guidance suggests storing access tokens in memory with refresh tokens in HttpOnly cookies to balance CSRF and XSS risks.[^10]
**Findings & Suggestions**
- Ensure frontend never writes the JWT token to `localStorage` or `sessionStorage`; prefer HttpOnly cookies and/or in-memory access tokens per JWT security checklists.[^10]
- Use `SameSite=Lax` or `Strict` plus `Secure` flag on cookies; backend should set these flags to mitigate CSRF and cookie theft, as recommended in TS+web security guides.[^9][^10]
## CI/CD & Testing Review
### CI Pipeline
- Test job (CI):
- Backend coverage: type check, Clippy with `-D warnings`, fmt check, unit tests, `cargo audit` with one specific advisory ignored (RUSTSEC-2023-0071), and Docker build.
- Frontend coverage: pnpm install, svelte-kit sync, Playwright browser install, TypeScript check via `tsgo` and `svelte-check`, build, Playwright E2E tests against backend brought up via `make test-up`.
- Failure path uploads Playwright artifacts for debugging.
**Findings & Suggestions**
- Ignoring `RUSTSEC-2023-0071` should be justified in the repo (e.g., README/SECURITY.md note) to document risk acceptance; OWASP and RustSec guidelines recommend handling advisories explicitly rather than silently ignoring them.[^3]
- Consider adding `cargo audit --deny-warnings` in CI once all advisories are resolved to prevent new vulnerabilities from creeping in.[^3]
- Add `pnpm audit` or `npm audit` equivalent carefully (with allowlist where necessary) to monitor JS dependency CVEs, aligning with TS and frontend security best practices.[^9]
### Release Pipeline
- Re-runs critical checks before building and pushing image and deploying via Helm; uses tag name as image tag and also pushes `latest`.
**Findings & Suggestions**
- Using `latest` tag is convenient but can obscure which version is actually running; many release engineering guides recommend avoiding `latest` in production and relying on immutable tags only.[^6]
- Helm upgrade uses `--wait --timeout 5m`, which is good; consider adding health/liveness/readiness probes in the Helm chart (if not already defined) to allow Kubernetes to verify app health before rollout completes.[^6]
## Security Audit Summary
### Strengths
- Modern Rust backend stack (Axum, SQLx, jsonwebtoken, bcrypt) with clear error handling and tracing.[^3]
- JWT usage is structured; tokens carry minimal data (id, email, role, exp) and are injected into handlers via typed Axum extractor.
- CI pipeline is extensive: type checks, linting, formatting, unit tests, E2E tests, cargo-audit, Docker build.
- Release pipeline uses Helm and secrets for registry and kubeconfig, with environment variables for image/namespace configuration.
### Issues & Risks
- JWT validation uses default `Validation`, not explicitly restricting algorithms or confirming `iss`/`aud`, which is discouraged by JWT crate best-practice discussions.[^11][^12]
- Token lifetime is 7 days; guidance from JWT and OAuth security resources recommends much shorter access tokens with refresh token rotation.[^8][^10]
- bcrypt is acceptable but no longer state-of-the-art; Argon2id is generally recommended for new password hashing deployments.[^1][^10]
- CI ignores `RUSTSEC-2023-0071` without a documented rationale in code; ignoring advisories without documentation is flagged as bad practice in security tooling docs.[^3]
- No visible JS dependency audit step; frontend best-practice checklists call for regular dependency scanning.[^9]
- Cookie flags (HttpOnly, SameSite, Secure) and CSRF protection are not visible from backend code snippet; recommended by TS/web security checklists.[^10][^9]
- `latest` Docker tag usage in release; release engineering guides generally recommend immutable tags only.[^6]
### Recommended Actions (Prioritized)
1. **Harden JWT validation**
- Explicitly set allowed algorithms, enable `validate_exp`, and consider adding `aud`/`iss` checks.[^12][^11]
- Consider reducing access token lifetime and introducing refresh tokens.
2. **Improve password hashing posture**
- Plan migration path to Argon2id for new passwords and rehash on login; keep bcrypt verification for legacy hashes.[^1][^10]
3. **Document and re-evaluate `cargo audit` ignore**
- Add a comment or SECURITY.md entry explaining the risk of `RUSTSEC-2023-0071` and why it is acceptable, and track upstream fix to eventually drop the ignore.[^3]
4. **Add JS dependency scanning in CI**
- Use `pnpm audit` or an external scanner (e.g., snyk) with a curated allowlist.[^9]
5. **Cookie & CSRF Hardening**
- Ensure JWT cookies use `HttpOnly`, `Secure`, and `SameSite=Lax/Strict` flags and that state-changing endpoints enforce CSRF protections where relevant.[^10][^9]
6. **Release Tagging Improvements**
- Consider dropping `latest` tag in production and relying solely on versioned tags; ensure Helm values/overrides always reference immutable tags.[^6]
7. **Operational Safeguards for Test Mode**
- Enforce that `TT_TEST_MODE` cannot be set in production (e.g., check an env like `ENV=prod` and panic if both are set) to guarantee that `/__test__/reset` never exists in prod.[^7][^8]
These changes would bring the project in line with current Rust 1.95, SvelteKit, TypeScript, and JWT security best practices, while preserving its already solid architecture and testing setup.[^5][^8]
---
## References
1. [Riktastic/Axium: An example API built with Rust, Axum, SQLx, and ...](https://github.com/Riktastic/Axium) - Axium is a high-performance, security-focused API boilerplate built using Rust, Axum, SQLx, S3, Redi...
2. [An ergonomic pattern for SQLx queries in Axum - Joshka.net](https://www.joshka.net/axum-sqlx-queries-pattern/) - In this post, I'll show an easy and ergonomic pattern for connecting to a database using SQLx and Ax...
3. [Building a REST API with Axum + Sqlx](https://carlosmv.hashnode.dev/creating-a-rest-api-with-axum-sqlx-rust) - I started to use Axum a few weeks ago, honestly, I'm a fan of the framework, so I'm writing this...
4. [How To Best Use Typescript for Props In Svelte 5 Project (VS Code)?](https://www.reddit.com/r/sveltejs/comments/1i6igz0/how_to_best_use_typescript_for_props_in_svelte_5/) - I am relatively new to TypeScript and am starting to add it into a Svelte 5 project. For a proof of ...
5. [Best practices • Svelte Docs](https://svelte.dev/docs/svelte/best-practices) - This document outlines some best practices that will help you write fast, robust Svelte apps. It is ...
6. [Migrating 8 SvelteKit Sites to Vite 8 in a day: What We Learned](https://cogley.jp/articles/migrating-sveltekit-to-vite-8) - If your SvelteKit guide references rollupOptions , update those references to rolldownOptions . And ...
7. [Building REST APIs with Rust and Axum: A Practical Beginner's Guide](https://noqta.tn/en/tutorials/rust-axum-rest-api-beginner-guide-2026) - Learn how to build fast, safe REST APIs using Rust and the Axum web framework. This step-by-step gui...
8. [Rust App Security: Master OAuth 2.0 & JWT](https://codezup.com/securing-rust-oauth-jwt/) - Secure your Rust applications with OAuth 2.0 and JWT! Learn step-by-step implementation for robust a...
9. [Typescript Application Security from A to Z: A Guide to Protecting ...](https://dev.to/devsdaddy/typescript-application-security-from-a-to-z-a-guide-to-protecting-against-obvious-and-55nh) - This article specifically provides simplified attack methods and vulnerability examples to make it e...
10. [Password Hashing](https://oneuptime.com/blog/post/2026-01-07-rust-jwt-authentication/view) - Learn how to implement secure JWT authentication in Rust applications. This guide covers token gener...
11. [Validate JWT using RS384 in Rust - SSOJet](https://ssojet.com/jwt-validation/validate-jwt-using-rs384-in-rust/) - Validate JWTs with RS384 in Rust. Secure your APIs by verifying token signatures efficiently and rel...
12. [JSON Web Token -- some investigative studies on crate ...](https://dev.to/behainguyen/rust-json-web-token-some-investigative-studies-on-crate-jsonwebtoken-1mch) - Regarding crate jsonwebtoken, the primary question is still how to check if a token is still valid,....
13. [Writing my first Rust crate: jsonwebtoken](https://www.vincentprouillet.com/blog/writing-my-first-crate/) - Experience writing a JWT library in Rust

51
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,51 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import svelteParser from 'svelte-eslint-parser';
/** @type {import('eslint').Linter.Config[]} */
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts'],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: ts.parser,
extraFileExtensions: ['.svelte']
}
}
},
{
ignores: ['build/', '.svelte-kit/', 'dist/']
},
{
rules: {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_'
}
],
'@typescript-eslint/no-explicit-any': 'warn',
'svelte/no-at-html-tags': 'error', // Catch XSS sinks
'svelte/require-each-key': 'warn',
'svelte/no-navigation-without-resolve': 'off', // Noisy
'svelte/no-useless-children-snippet': 'warn'
}
}
];

View File

@@ -8,18 +8,26 @@
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.59.1",
"@sveltejs/adapter-static": "latest",
"@sveltejs/kit": "latest",
"@sveltejs/vite-plugin-svelte": "latest",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.59.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^22",
"@typescript/native-preview": "^7.0.0-dev",
"svelte": "5.55.5",
"svelte-check": "latest",
"typescript": "latest",
"vite": "latest"
"eslint": "^10.3.0",
"eslint-plugin-svelte": "^3.17.1",
"globals": "^17.6.0",
"svelte": "^5.55.5",
"svelte-check": "^4",
"svelte-eslint-parser": "^1.6.0",
"typescript": "^5",
"typescript-eslint": "^8.59.1",
"vite": "^8.0.10"
}
}

1005
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,11 +41,13 @@
if (!draggingId || !editable) return;
const index = elements.findIndex((el: LayoutElement) => el.id === draggingId);
if (index === -1) return;
const el = elements[index];
if (!el) return;
const newX = Math.round((e.clientX - startX) / 10) * 10 / 40;
const newY = Math.round((e.clientY - startY) / 10) * 10 / 40;
elements[index] = { ...elements[index], x: newX, y: newY };
elements[index] = { ...el, x: newX, y: newY };
}
function handleMouseUp() {
@@ -78,8 +80,7 @@
<rect width="100%" height="100%" fill="url(#grid)" />
{/if}
{#each elements as el}
<!-- svelte-ignore a11y_click_events_have_key_events -->
{#each elements as el (el.id)}
<g
transform="translate({el.x * GRID_SIZE}, {el.y * GRID_SIZE})"
class="element {el.type}"

View File

@@ -1,22 +1,41 @@
import { browser } from '$app/environment';
import { get } from 'svelte/store';
import { token } from './auth';
import type {
Course, Tutor, Student, Room, Session, Slot, Attendance, Note,
LayoutElement, SessionAttendance, CheckinInfo
} from './types';
import { auth } from './auth.svelte';
const BASE = '/api';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const $token = get(token);
let isRefreshing = false;
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...($token ? { Authorization: `Bearer ${$token}` } : {}),
...(!(init?.body instanceof FormData) && { 'Content-Type': 'application/json' }),
...init?.headers,
}
});
if (res.status === 401 && browser) {
// Handle unauthorized
if (!isRefreshing && path !== '/auth/refresh') {
isRefreshing = true;
try {
const refreshed = await fetch(BASE + '/auth/refresh', { method: 'POST', credentials: 'include' });
if (refreshed.ok) {
isRefreshing = false;
return request<T>(path, init);
}
} catch (_e) {
// refresh failed, fall through to logout
} finally {
isRefreshing = false;
}
}
auth.logout();
throw new Error('Unauthorized');
}
if (!res.ok) {
@@ -31,37 +50,36 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
auth: {
login: (email: string, password: string) =>
request<{token: string, is_superadmin: boolean}>('/auth/login', {
request<{is_superadmin: boolean}>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
me: () => request<{id: number, email: string, is_superadmin: boolean}>('/auth/me'),
logout: () => request<void>('/auth/logout', { method: 'POST' }),
},
admin: {
courses: {
list: () => request<any[]>('/admin/courses'),
list: () => request<Course[]>('/admin/courses'),
create: (name: string, semester: string) =>
request<any>('/admin/courses', {
request<Course>('/admin/courses', {
method: 'POST',
body: JSON.stringify({ name, semester })
}),
listStudents: (course_id: number) => request<any[]>(`/admin/courses/${course_id}/students`),
listStudents: (course_id: number) => request<Student[]>(`/admin/courses/${course_id}/students`),
addStudent: (course_id: number, name: string) =>
request<any>(`/admin/courses/${course_id}/students`, {
request<Student>(`/admin/courses/${course_id}/students`, {
method: 'POST',
body: JSON.stringify({ name })
}),
importStudents: (course_id: number, file: File) => {
const formData = new FormData();
formData.append('file', file);
return fetch(`${BASE}/admin/courses/${course_id}/students/import`, {
return request<{count: number}>(`/admin/courses/${course_id}/students/import`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${get(token)}`
},
body: formData
}).then(res => res.json());
});
},
listTutors: (course_id: number) => request<any[]>(`/admin/courses/${course_id}/tutors`),
listTutors: (course_id: number) => request<Tutor[]>(`/admin/courses/${course_id}/tutors`),
assignTutor: (course_id: number, tutor_id: number) =>
request<void>(`/admin/courses/${course_id}/tutors`, {
method: 'POST',
@@ -71,9 +89,9 @@ export const api = {
request<void>(`/admin/courses/${course_id}/tutors/${tutor_id}`, { method: 'DELETE' }),
},
tutors: {
list: () => request<any[]>('/admin/tutors'),
create: (tutor: any) =>
request<any>('/admin/tutors', {
list: () => request<Tutor[]>('/admin/tutors'),
create: (tutor: Partial<Tutor> & { password?: string }) =>
request<Tutor>('/admin/tutors', {
method: 'POST',
body: JSON.stringify(tutor)
}),
@@ -81,40 +99,40 @@ export const api = {
},
students: {
delete: (id: number) => request<void>(`/admin/students/${id}`, { method: 'DELETE' }),
getAttendance: (id: number) => request<any[]>(`/admin/students/${id}/attendance`),
getNotes: (id: number) => request<any[]>(`/admin/students/${id}/notes`),
getAttendance: (id: number) => request<Attendance[]>(`/admin/students/${id}/attendance`),
getNotes: (id: number) => request<Note[]>(`/admin/students/${id}/notes`),
},
rooms: {
list: () => request<any[]>('/admin/rooms'),
create: (name: string, layout: any[]) =>
request<any>('/admin/rooms', {
list: () => request<Room[]>('/admin/rooms'),
create: (name: string, layout: LayoutElement[]) =>
request<Room>('/admin/rooms', {
method: 'POST',
body: JSON.stringify({ name, layout })
}),
get: (id: number) => request<any>(`/admin/rooms/${id}`),
updateLayout: (id: number, layout: any[]) =>
request<any>(`/admin/rooms/${id}/layout`, {
get: (id: number) => request<Room>(`/admin/rooms/${id}`),
updateLayout: (id: number, layout: LayoutElement[]) =>
request<Room>(`/admin/rooms/${id}/layout`, {
method: 'PUT',
body: JSON.stringify(layout)
}),
},
sessions: {
list: (course_id: number) => request<any[]>(`/admin/sessions?course_id=${course_id}`),
list: (course_id: number) => request<Session[]>(`/admin/sessions?course_id=${course_id}`),
create: (course_id: number, week_nr: number, date: string) =>
request<any>('/admin/sessions', {
request<Session>('/admin/sessions', {
method: 'POST',
body: JSON.stringify({ course_id, week_nr, date })
}),
getAttendance: (id: number) => request<any>(`/admin/sessions/${id}/attendance`),
getAttendance: (id: number) => request<SessionAttendance>(`/admin/sessions/${id}/attendance`),
},
slots: {
create: (session_id: number, tutor_id: number, start_time: string, end_time: string, room_id?: number) =>
request<any>('/admin/slots', {
request<Slot>('/admin/slots', {
method: 'POST',
body: JSON.stringify({ session_id, tutor_id, start_time, end_time, room_id })
}),
updateStatus: (id: number, status: string) =>
request<any>(`/admin/slots/${id}/status`, {
request<Slot>(`/admin/slots/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status })
}),
@@ -126,7 +144,7 @@ export const api = {
}),
deleteAttendance: (slot_id: number, student_id: number) =>
request<void>(`/admin/slots/${slot_id}/attendance/${student_id}`, { method: 'DELETE' }),
getNotes: (id: number) => request<any[]>(`/admin/slots/${id}/notes`),
getNotes: (id: number) => request<Note[]>(`/admin/slots/${id}/notes`),
upsertNote: (slot_id: number, student_id: number, content: string) =>
request<void>(`/admin/slots/${slot_id}/notes/${student_id}`, {
method: 'PUT',
@@ -142,10 +160,10 @@ export const api = {
}
},
checkin: {
getInfo: (code: string) => request<any>(`/checkin/${code}`),
getStudents: (code: string) => request<any[]>(`/checkin/${code}/students`),
getInfo: (code: string) => request<CheckinInfo>(`/checkin/${code}`),
getStudents: (code: string) => request<Student[]>(`/checkin/${code}/students`),
post: (code: string, student_id: number, seat_id?: string) =>
request<any>('/checkin', {
request<Attendance>('/checkin', {
method: 'POST',
body: JSON.stringify({ code, student_id, seat_id })
}),

View File

@@ -0,0 +1,58 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { api } from './api';
let _isSuperadmin = $state(false);
let _initialized = $state(false);
let _authenticated = $state(false);
export const auth = {
get isSuperadmin() { return _isSuperadmin; },
get initialized() { return _initialized; },
get authenticated() { return _authenticated; },
async init() {
if (!browser || _initialized) return;
try {
let res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' });
if (!res.ok) {
const refreshed = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
if (refreshed.ok) {
res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' });
}
}
if (res.ok) {
const me = await res.json();
_isSuperadmin = me.is_superadmin;
_authenticated = true;
} else {
_isSuperadmin = false;
_authenticated = false;
}
} catch (_e) {
_isSuperadmin = false;
_authenticated = false;
} finally {
_initialized = true;
}
},
setAuthenticated(isSuperadmin: boolean) {
_isSuperadmin = isSuperadmin;
_authenticated = true;
_initialized = true;
},
async logout() {
try {
await api.auth.logout();
} catch (_e) {
console.error('logout failed', _e);
}
_isSuperadmin = false;
_authenticated = false;
if (browser) {
goto('/admin/login');
}
}
};

View File

@@ -1,28 +0,0 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export const token = writable<string | null>(
browser ? localStorage.getItem('token') : null
);
export const isSuperadmin = writable<boolean>(
browser ? localStorage.getItem('is_superadmin') === 'true' : false
);
if (browser) {
token.subscribe((value) => {
if (value) {
localStorage.setItem('token', value);
} else {
localStorage.removeItem('token');
}
});
isSuperadmin.subscribe((value) => {
localStorage.setItem('is_superadmin', value ? 'true' : 'false');
});
}
export function logout() {
token.set(null);
isSuperadmin.set(false);
}

View File

@@ -76,7 +76,7 @@
await api.admin.slots.upsertNote(slotId, selectedStudentId, noteContent);
const now = new Date();
savedAt = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
} catch (_) {
} catch {
// silent — user sees no feedback on transient failure
}
}
@@ -98,7 +98,7 @@
<!-- Roster list -->
<div class="scroll" style="overflow-y:auto;max-height:220px;padding:6px 0">
{#each present as s}
{#each present as s (s.id)}
{@const isSel = s.id === selectedStudentId}
<button
style="width:100%;text-align:left;border:none;background:{isSel ? 'rgba(31,27,22,0.06)' : 'transparent'};padding:7px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;border-left:{isSel ? '3px solid var(--ink)' : '3px solid transparent'}"
@@ -115,7 +115,7 @@
<span class="mono tiny" style="color:var(--ink-4)">{checkinTime(s.id)}</span>
</button>
{/each}
{#each absent as s}
{#each absent as s (s.id)}
<button
style="width:100%;text-align:left;border:none;background:transparent;padding:7px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;opacity:0.55;border-left:3px solid transparent"
class="row-hover"
@@ -162,7 +162,7 @@
<!-- Quick tags -->
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px">
{#each TAGS as tag}
{#each TAGS as tag (tag)}
<button
class="pill closed"
style="border-color:var(--rule);cursor:pointer;font-family:var(--sans);text-transform:none;font-size:11px;letter-spacing:0"

View File

@@ -136,14 +136,14 @@
</div>
<!-- Tables -->
{#each TABLES as t}
{#each TABLES as t (t.id)}
<div style="position:absolute;left:{t.x}px;top:{t.y}px;width:{t.w}px;height:{t.h}px;background:#e8dec5;border:1.5px solid var(--ink-2);border-radius:3px;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-size:22px;color:rgba(31,27,22,0.35);font-style:italic">
{t.label}
</div>
{/each}
<!-- Seats -->
{#each SEATS as seat}
{#each SEATS as seat (seat.id)}
{@const s = seatStyle(seat)}
<button
style="position:absolute;left:{seat.x - 18}px;top:{seat.y - 18}px;width:36px;height:36px;border-radius:50%;background:{s.bg};border:1.5px solid {s.border};cursor:{onSeatClick && variant !== 'student-self' ? 'pointer' : 'default'};display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-weight:600;font-size:11px;color:{s.labelColor};padding:0;box-shadow:{s.shadow};transition:background 120ms,border-color 120ms"

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { isSuperadmin } from '$lib/auth';
import { auth } from '$lib/auth.svelte';
const {
activePath,
@@ -67,8 +67,8 @@
<!-- Navigation -->
<nav style="display:flex;flex-direction:column;gap:1px">
<div class="eyebrow" style="margin-bottom:6px">Navigation</div>
{#each navItems as item}
{#if !item.superadmin || $isSuperadmin}
{#each navItems as item (item.id)}
{#if !item.superadmin || auth.isSuperadmin}
{@const active = isActive(item)}
<a
href={item.href}

View File

@@ -68,3 +68,21 @@ export interface Note {
content: string;
updated_at: string;
}
export interface SessionAttendance {
students: Student[];
slots: Slot[];
attendances: Attendance[];
}
export interface CheckinAttendance {
seat_id: string | null;
student_id: number;
is_mine: boolean;
}
export interface CheckinInfo {
slot: Slot;
layout: LayoutElement[] | null;
attendances: CheckinAttendance[];
}

View File

@@ -1,9 +1,10 @@
<script lang="ts">
import { token } from '$lib/auth';
import { auth } from '$lib/auth.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
goto($token ? '/admin' : '/admin/login');
onMount(async () => {
await auth.init();
goto(auth.authenticated ? '/admin' : '/admin/login');
});
</script>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { token, logout } from '$lib/auth';
import { auth } from '$lib/auth.svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
@@ -12,39 +12,46 @@
let course = $state<Course | null>(null);
const isLoginRoute = $derived($page.url.pathname === '/admin/login');
onMount(async () => {
if (!$token) {
await auth.init();
if (isLoginRoute) return;
if (!auth.authenticated) {
goto('/admin/login');
return;
}
try {
const courses = await api.admin.courses.list();
if (courses.length > 0) course = courses[0];
} catch (_) {}
if (courses.length > 0) course = courses[0] ?? null;
} catch (_err) {
console.error('failed to fetch courses');
}
});
$effect(() => {
if (!$token) goto('/admin/login');
if (auth.initialized && !auth.authenticated && !isLoginRoute) goto('/admin/login');
});
function handleLogout() {
logout();
goto('/admin/login');
}
const activePath = $derived($page.url.pathname);
</script>
{#if $token}
<TutorShell
{activePath}
courseName={course?.name ?? ''}
semester={course?.semester ?? ''}
>
{#snippet children()}
{#if auth.initialized}
{#if auth.authenticated}
<TutorShell
{activePath}
courseName={course?.name ?? ''}
semester={course?.semester ?? ''}
>
{@render children()}
{/snippet}
</TutorShell>
</TutorShell>
{:else if isLoginRoute}
{@render children()}
{:else}
<div style="padding: 2rem; text-align: center;">Redirecting to login...</div>
{/if}
{:else}
{@render children()}
<div style="padding: 2rem; text-align: center; font-family: var(--serif);">
Wird geladen...
</div>
{/if}

View File

@@ -16,7 +16,8 @@
loading = true;
try {
courses = await api.admin.courses.list();
if (courses.length > 0) selectedCourseId = courses[0].id;
const first = courses[0];
if (first) selectedCourseId = first.id;
} catch (e) {
console.error(e);
} finally {
@@ -98,7 +99,7 @@
style="font-size:12px"
bind:value={selectedCourseId}
>
{#each courses as course}
{#each courses as course (course.id)}
<option value={course.id}>{course.name} ({course.semester})</option>
{/each}
</select>
@@ -112,9 +113,9 @@
<!-- Stat row -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px">
<StatCard
label="Offene Slots"
label="Anwesenheit offen"
value={openSlots.length}
hint={openSlots.length > 0 ? `Code: ${openSlots[0].code ?? '—'}` : 'Kein Slot offen'}
hint={openSlots[0] ? `Code: ${openSlots[0].code ?? '—'}` : 'Kein Slot offen'}
accent={openSlots.length > 0 ? 'var(--green)' : undefined}
/>
<StatCard
@@ -165,7 +166,7 @@
</tr>
</thead>
<tbody>
{#each slotRows as { slot, session }, i}
{#each slotRows as { slot, session }, i (slot.id)}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px">{weekLabel(session.week_nr)}</span>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Session, Student, Attendance } from '$lib/types';
import type { Course, Session, Student, Attendance, SessionAttendance } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
@@ -16,7 +16,8 @@
onMount(async () => {
courses = await api.admin.courses.list();
if (courses.length > 0) selectedCourseId = courses[0].id;
const first = courses[0];
if (first) selectedCourseId = first.id;
});
$effect(() => {
@@ -37,8 +38,8 @@
const slotIds: number[] = [];
const newMap: Record<number, Attendance[]> = {};
perSession.forEach((d: { students: Student[]; slots: any[]; attendances: Attendance[] }) => {
(d.slots ?? []).forEach((slot: any) => {
perSession.forEach((d: SessionAttendance) => {
(d.slots ?? []).forEach((slot) => {
slotIds.push(slot.id);
newMap[slot.id] = (d.attendances ?? []).filter((a: Attendance) => a.slot_id === slot.id);
});
@@ -63,7 +64,7 @@
}
if (selectedCourseId) await loadMatrix(selectedCourseId);
} catch (e) {
alert(e);
if (e instanceof Error) alert(e.message);
}
}
@@ -104,7 +105,7 @@
<div style="display:flex;gap:8px;align-items:center">
{#if courses.length > 1}
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
{#each courses as course}
{#each courses as course (course.id)}
<option value={course.id}>{course.name}</option>
{/each}
</select>
@@ -146,7 +147,7 @@
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">#</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Studierende:r</th>
{#each sessions as session, i}
{#each sessions as session (session.id)}
<th style="padding:10px 10px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:center">
W{String(session.week_nr).padStart(2, '0')}
</th>
@@ -156,7 +157,7 @@
</tr>
</thead>
<tbody>
{#each students as student, i}
{#each students as student, i (student.id)}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px;color:var(--ink-4)">{i + 1}</span>
@@ -169,14 +170,17 @@
<span>{student.name}</span>
</div>
</td>
{#each sessions as session}
{@const slotIds = (session.slots ?? []).map((sl: any) => sl.id)}
{#each sessions as session (session.id)}
{@const slotIds = (session.slots ?? []).map((sl) => sl.id)}
{@const sessionPresent = slotIds.some((sid: number) => isPresent(sid, student.id))}
<td style="padding:10px;text-align:center">
{#if slotIds.length > 0}
<button
style="width:24px;height:24px;border-radius:3px;border:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;background:{sessionPresent ? 'rgba(74,107,58,0.14)' : 'transparent'}"
onclick={() => toggleAttendance(slotIds[0], student.id)}
onclick={() => {
const sid = slotIds[0];
if (sid !== undefined) toggleAttendance(sid, student.id);
}}
title={sessionPresent ? 'Anwesend klicken zum Entfernen' : 'Abwesend klicken zum Eintragen'}
>
{#if sessionPresent}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { isSuperadmin } from '$lib/auth';
import { auth } from '$lib/auth.svelte';
import type { Course, Tutor } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
@@ -18,7 +18,7 @@
async function loadData() {
courses = await api.admin.courses.list();
if ($isSuperadmin) {
if (auth.isSuperadmin) {
allTutors = await api.admin.tutors.list();
for (const course of courses) {
assignedTutors[course.id] = await api.admin.courses.listTutors(course.id);
@@ -64,7 +64,7 @@
</header>
<!-- Create course form (Superadmin only) -->
{#if $isSuperadmin}
{#if auth.isSuperadmin}
<section class="card" style="overflow:hidden">
<div style="padding:14px 18px;border-bottom:1px solid var(--rule)">
<div class="serif" style="font-size:18px;font-weight:500">Neuen Kurs anlegen</div>
@@ -95,24 +95,24 @@
<thead>
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Name / Semester</th>
{#if $isSuperadmin}
{#if auth.isSuperadmin}
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Tutor:innen</th>
{/if}
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:right">Aktionen</th>
</tr>
</thead>
<tbody>
{#each courses as course, i}
{#each courses as course, i (course.id)}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">
<div class="serif" style="font-weight:500;font-size:15px">{course.name}</div>
<div class="tiny" style="color:var(--ink-4);font-family:var(--mono)">{course.semester}</div>
</td>
{#if $isSuperadmin}
{#if auth.isSuperadmin}
<td style="padding:12px 14px">
<div style="display:flex;flex-wrap:wrap;gap:4px;max-width:300px">
{#each assignedTutors[course.id] ?? [] as tutor}
{#each assignedTutors[course.id] ?? [] as tutor (tutor.id)}
<span class="pill closed" style="font-size:10px;padding:1px 6px">
{tutor.name}
<button
@@ -122,7 +122,7 @@
>×</button>
</span>
{/each}
<select
class="tiny"
style="background:none;border:1px dashed var(--rule);border-radius:999px;padding:1px 6px;cursor:pointer"
@@ -130,14 +130,13 @@
value=""
>
<option value="" disabled>+ Hinzufügen</option>
{#each allTutors.filter(t => !assignedTutors[course.id]?.some(at => at.id === t.id)) as t}
{#each allTutors.filter(t => !assignedTutors[course.id]?.some(at => at.id === t.id)) as t (t.id)}
<option value={t.id}>{t.name}</option>
{/each}
</select>
</div>
</td>
{/if}
<td style="padding:12px 14px;text-align:right">
<div style="display:flex;gap:6px;justify-content:flex-end">
<a href="/admin/students" class="btn ghost sm">Studierende</a>

View File

@@ -11,7 +11,8 @@
onMount(async () => {
courses = await api.admin.courses.list();
if (courses.length > 0) selectedCourseId = courses[0].id;
const first = courses[0];
if (first) selectedCourseId = first.id;
});
$effect(() => {
@@ -19,15 +20,6 @@
api.admin.sessions.list(selectedCourseId).then((res: Session[]) => sessions = res);
}
});
function download(url: string, filename: string) {
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
</script>
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
@@ -76,7 +68,7 @@
{#if courses.length > 0}
<select class="input" style="font-size:12px;width:160px" bind:value={selectedCourseId}>
{#each courses as course}
{#each courses as course (course.id)}
<option value={course.id}>{course.name}</option>
{/each}
</select>
@@ -108,7 +100,7 @@
{:else}
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tbody>
{#each sessions as session, i}
{#each sessions as session, i (session.id)}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 16px">
<div class="serif" style="font-weight:500;font-size:14px">Woche {String(session.week_nr).padStart(2, '0')}</div>

View File

@@ -10,7 +10,8 @@
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
import Tally from '$lib/components/Tally.svelte';
const slotId = $derived(parseInt(($page.params as Record<string, string>).slotId));
const slotIdStr = ($page.params as Record<string, string>).slotId;
const slotId = $derived(slotIdStr ? parseInt(slotIdStr) : 0);
let slot = $state<Slot | null>(null);
let session = $state<Session | null>(null);
@@ -100,10 +101,6 @@
const presentCount = $derived(attendances.length);
const absentCount = $derived(students.length - presentCount);
const bonusCount = $derived(students.filter((s: Student) => {
// Bonus eligibility would require cross-session data; show attendees as placeholder
return attendances.some((a: Attendance) => a.student_id === s.id);
}).length);
function weekLabel(n: number): string {
return `W${String(n).padStart(2, '0')}`;
@@ -184,7 +181,7 @@
</div>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tbody>
{#each students as student, i}
{#each students as student, i (student.id)}
{@const present = attendances.some((a: Attendance) => a.student_id === student.id)}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:8px 14px">

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import { token, isSuperadmin } from '$lib/auth';
import { auth } from '$lib/auth.svelte';
import { goto } from '$app/navigation';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
@@ -14,8 +14,7 @@
error = '';
try {
const res = await api.auth.login(email, password);
token.set(res.token);
isSuperadmin.set(res.is_superadmin);
auth.setAuthenticated(res.is_superadmin);
goto('/admin');
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Ungültige Anmeldedaten';

View File

@@ -16,7 +16,9 @@
await api.admin.rooms.create(newRoomName, []);
newRoomName = '';
rooms = await api.admin.rooms.list();
} catch (_) {}
} catch {
console.error('failed to fetch rooms');
}
}
</script>
@@ -48,7 +50,7 @@
</tr>
</thead>
<tbody>
{#each rooms as room, i}
{#each rooms as room, i (room.id)}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">{room.name}</td>
<td style="padding:12px 14px;text-align:right">

View File

@@ -5,7 +5,8 @@
import RoomCanvas from '$lib/RoomCanvas.svelte';
import type { Room, LayoutElement } from '$lib/types';
const roomId = $derived(parseInt(($page.params as Record<string, string>).roomId));
const roomIdStr = ($page.params as Record<string, string>).roomId;
const roomId = $derived(roomIdStr ? parseInt(roomIdStr) : 0);
let room = $state<Room | null>(null);
@@ -23,7 +24,9 @@
if (!room) return;
try {
await api.admin.rooms.updateLayout(room.id, room.layout);
} catch (_) {}
} catch {
console.error('failed to save layout');
}
}
function addElement(type: LayoutElement['type']) {

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Room, Session, Slot } from '$lib/types';
import type { Course, Room, Session } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import StatusPill from '$lib/components/StatusPill.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
@@ -12,7 +12,7 @@
let sessions = $state<Session[]>([]);
let weekNr = $state(1);
let date = $state(new Date().toISOString().split('T')[0]);
let date = $state(new Date().toISOString().split('T')[0] ?? '');
let selectedSessionId = $state<number | null>(null);
let slotTutorId = $state<number | null>(null);
@@ -23,7 +23,8 @@
onMount(async () => {
courses = await api.admin.courses.list();
rooms = await api.admin.rooms.list();
if (courses.length > 0) selectedCourseId = courses[0].id;
const first = courses[0];
if (first) selectedCourseId = first.id;
});
$effect(() => {
@@ -36,10 +37,11 @@
async function createSession(e: Event) {
e.preventDefault();
if (!selectedCourseId) return;
const courseId = selectedCourseId;
if (courseId === null) return;
try {
await api.admin.sessions.create(selectedCourseId, weekNr, date);
await loadSessions(selectedCourseId);
await api.admin.sessions.create(courseId, weekNr, date);
await loadSessions(courseId);
weekNr++;
} catch (e) { alert(e); }
}
@@ -81,7 +83,7 @@
</div>
{#if courses.length > 1}
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
{#each courses as course}
{#each courses as course (course.id)}
<option value={course.id}>{course.name} ({course.semester})</option>
{/each}
</select>
@@ -114,7 +116,7 @@
</div>
{:else}
<div style="display:flex;flex-direction:column;gap:12px">
{#each sessions as session}
{#each sessions as session (session.id)}
<section class="card" style="overflow:hidden">
<div style="padding:12px 16px;background:rgba(0,0,0,0.02);border-bottom:1px solid var(--rule);display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;align-items:center;gap:10px">
@@ -133,7 +135,7 @@
{:else}
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tbody>
{#each session.slots ?? [] as slot, i}
{#each session.slots ?? [] as slot, i (slot.id)}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:10px 16px">
<span class="mono" style="font-size:12px">{slot.start_time}{slot.end_time}</span>
@@ -189,7 +191,7 @@
<label for="slot-room" class="tiny" style="color:var(--ink-3)">Raum (optional)</label>
<select id="slot-room" class="input" bind:value={slotRoomId}>
<option value={null}>Kein Raum</option>
{#each rooms as room}
{#each rooms as room (room.id)}
<option value={room.id}>{room.name}</option>
{/each}
</select>

View File

@@ -13,7 +13,8 @@
onMount(async () => {
courses = await api.admin.courses.list();
if (courses.length > 0) selectedCourseId = courses[0].id;
const first = courses[0];
if (first) selectedCourseId = first.id;
});
$effect(() => {
@@ -76,7 +77,7 @@
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
{#if courses.length > 1}
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
{#each courses as course}
{#each courses as course (course.id)}
<option value={course.id}>{course.name} ({course.semester})</option>
{/each}
</select>
@@ -128,7 +129,7 @@
</tr>
</thead>
<tbody>
{#each filtered as student, i}
{#each filtered as student, i (student.id)}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px;color:var(--ink-4)">{i + 1}</span>

View File

@@ -34,8 +34,8 @@
await api.admin.tutors.create(newTutor);
newTutor = { name: '', email: '', password: '', is_superadmin: false };
await loadTutors();
} catch (err: any) {
alert(err.message);
} catch (err) {
if (err instanceof Error) alert(err.message);
}
}
@@ -44,8 +44,8 @@
try {
await api.admin.tutors.delete(id);
await loadTutors();
} catch (err: any) {
alert(err.message);
} catch (err) {
if (err instanceof Error) alert(err.message);
}
}
@@ -94,7 +94,7 @@
</tr>
</thead>
<tbody>
{#each tutors as tutor, i}
{#each tutors as tutor, i (tutor.id)}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">
<div style="display:flex;align-items:center;gap:10px">

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api';
import type { Slot, Student, Attendance } from '$lib/types';
import type { Slot, Student, Attendance, CheckinAttendance } from '$lib/types';
import SeatMap from '$lib/components/SeatMap.svelte';
import StatusPill from '$lib/components/StatusPill.svelte';
@@ -14,7 +14,7 @@
let slot = $state<Slot | null>(null);
let students = $state<Student[]>([]);
let attendances = $state<Attendance[]>([]);
let attendances = $state<CheckinAttendance[]>([]);
let myAttendance = $state<Attendance | null>(null);
let search = $state('');
@@ -29,8 +29,12 @@
try {
await loadInfo();
} catch (e: any) {
errorMsg = e.message ?? 'Fehler beim Laden.';
} catch (e) {
if (e instanceof Error) {
errorMsg = e.message ?? 'Fehler beim Laden.';
} else {
errorMsg = 'Fehler beim Laden.';
}
step = 'error';
}
});
@@ -38,11 +42,19 @@
async function loadInfo() {
const res = await api.checkin.getInfo(code);
slot = res.slot;
attendances = res.attendances ?? [];
// We don't have is_mine in the regular Attendance type, so we use CheckinAttendance locally
const checkinAttendances = res.attendances ?? [];
const mine = attendances.find((a: Attendance) => (a as any).is_mine);
if (mine) {
myAttendance = mine;
const mine = checkinAttendances.find((a: CheckinAttendance) => a.is_mine);
if (mine && slot) {
// Create a dummy Attendance object for compatibility with the state
myAttendance = {
id: 0,
slot_id: slot.id,
student_id: mine.student_id,
seat_id: mine.seat_id,
checked_in_at: new Date().toISOString()
};
}
if (slot?.status === 'locked') {
@@ -69,11 +81,15 @@
const res = await api.checkin.post(code, selectedStudent.id, seatId);
myAttendance = res;
await loadInfo();
} catch (e: any) {
if (e.message?.includes('already checked in') || e.message?.includes('409')) {
errorMsg = 'Dieser Platz ist bereits belegt.';
} catch (e) {
if (e instanceof Error) {
if (e.message?.includes('already checked in') || e.message?.includes('409')) {
errorMsg = 'Dieser Platz ist bereits belegt.';
} else {
errorMsg = e.message ?? 'Einchecken fehlgeschlagen.';
}
} else {
errorMsg = e.message ?? 'Einchecken fehlgeschlagen.';
errorMsg = 'Einchecken fehlgeschlagen.';
}
}
}
@@ -153,7 +169,7 @@
/>
<div style="display:flex;flex-direction:column;gap:4px;max-height:55vh;overflow-y:auto" class="scroll">
{#each filteredStudents as student}
{#each filteredStudents as student (student.id)}
<button
class="card"
style="padding:12px 16px;display:flex;align-items:center;gap:10px;text-align:left;border:none;cursor:pointer;background:var(--paper);width:100%"
@@ -263,7 +279,7 @@
/>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-height:55vh;overflow-y:auto" class="scroll">
{#each filteredStudents as student}
{#each filteredStudents as student (student.id)}
<button
class="card"
style="padding:12px 16px;display:flex;align-items:center;gap:10px;text-align:left;border:none;cursor:pointer;background:var(--paper);width:100%"

View File

@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test';
test.describe('Login page accessibility', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('renders login form without auth cookies (regression: redirect trap)', async ({ page }) => {
await page.goto('/admin/login');
await expect(page.locator('#email')).toBeVisible();
await expect(page.locator('#password')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
await expect(page.locator('text=Willkommen zurück')).toBeVisible();
await expect(page.locator('text=Redirecting to login')).not.toBeVisible();
});
test('unauthenticated /admin redirects to login form', async ({ page }) => {
await page.goto('/admin');
await page.waitForURL(/\/admin\/login/);
await expect(page.locator('#email')).toBeVisible();
});
});

View File

@@ -1,4 +1,4 @@
import { test as base, expect } from '@playwright/test';
import { test as base, expect, type Page } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
@@ -16,7 +16,7 @@ function getBaseURL(): string {
}
// Extends base test with a beforeEach that resets DB to clean seed state
export const test = base.extend<{ page: Parameters<Parameters<typeof base>[1]>[0]['page'] }>({
export const test = base.extend<{ page: Page }>({
page: async ({ page }, use) => {
const baseURL = getBaseURL();
const res = await page.request.post(`${baseURL}/__test__/reset`);

View File

@@ -6,7 +6,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function globalSetup() {
// Read env vars written by scripts/test-env.sh (make test-up calls it first)
const envFile = path.resolve(__dirname, '../../../data/test/.env');
const envFile = path.resolve(__dirname, '../../data/test/.env');
if (!fs.existsSync(envFile)) {
throw new Error('data/test/.env not found — run "make test-up" first');
}
@@ -24,20 +24,41 @@ async function globalSetup() {
body: JSON.stringify({ email: 'admin@tutortool.com', password: 'admin' }),
});
if (!res.ok) throw new Error(`Login failed: ${res.status} ${await res.text()}`);
const { token, is_superadmin } = await res.json() as { token: string; is_superadmin: boolean };
// Extract cookies from Set-Cookie headers
// Note: Use getSetCookie() in newer Node versions or handle joined string
const setCookies = (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() || res.headers.get('set-cookie')?.split(',') || [];
const cookies = setCookies.map((s: string) => {
const parts = s.split(';').map(p => p.trim());
const [nameValue] = parts;
if (!nameValue) return null;
const [name, value] = nameValue.split('=');
if (!name || !value) return null;
// Write Playwright storage state with localStorage pre-populated
const pathPart = parts.find(p => p.toLowerCase().startsWith('path='));
const path = pathPart ? pathPart.split('=')[1] : '/';
return {
name,
value,
domain: new URL(baseURL).hostname,
path: path || '/',
httpOnly: s.toLowerCase().includes('httponly'),
secure: s.toLowerCase().includes('secure'),
sameSite: 'Strict' as const,
};
}).filter(Boolean);
// Write Playwright storage state with cookies pre-populated
const authDir = path.resolve(__dirname, '.auth');
fs.mkdirSync(authDir, { recursive: true });
const storageState = {
cookies: [],
cookies,
origins: [
{
origin: baseURL,
localStorage: [
{ name: 'token', value: token },
{ name: 'is_superadmin', value: String(is_superadmin) },
],
localStorage: [],
},
],
};

View File

@@ -34,7 +34,7 @@ test.describe('Superadmin CRUD & UI Consistency', () => {
await expect(page.locator('text=Neuen Kurs anlegen')).toBeVisible();
const tutorSelect = page.locator('select >> text=+ Hinzufügen').first();
const tutorSelect = page.locator('select.tiny').first();
await expect(tutorSelect).toBeVisible();
});
});

View File

@@ -9,6 +9,8 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitAny": true,
"moduleResolution": "bundler",
"module": "esnext"
}