3 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
40 changed files with 1907 additions and 280 deletions

View File

@@ -47,6 +47,9 @@ 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
@@ -65,6 +68,9 @@ jobs:
- 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

View File

@@ -49,6 +49,9 @@ 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
@@ -92,7 +95,6 @@ jobs:
push: true
tags: |
${{ env.IMAGE }}:${{ github.ref_name }}
${{ env.IMAGE }}:latest
- name: Configure kubectl
run: |

View File

@@ -19,13 +19,17 @@ 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
# 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 # runs lint, then pnpm build and cargo build --release
make compose-up # docker compose build + start
# 🚨 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
@@ -47,7 +51,12 @@ 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**: 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.
- **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`.
@@ -55,6 +64,8 @@ make test-e2e # test-up + pnpm test:e2e in one step
- 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.). 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).
@@ -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

@@ -18,6 +18,8 @@ lint:
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
@@ -92,3 +94,12 @@ test-reset:
test-e2e:
$(MAKE) test-up
@. 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

@@ -13,10 +13,11 @@ 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", "trace"] }
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"] }

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,52 +1,86 @@
use crate::{AppState, error::AppError};
use axum::{RequestPartsExt, extract::FromRef, extract::FromRequestParts, http::request::Parts};
use axum_extra::extract::CookieJar;
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
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
}
pub fn encode_jwt(
id: i64,
email: &str,
is_superadmin: bool,
secret: &str,
refresh: bool,
) -> Result<String, AppError> {
let exp = (chrono::Utc::now() + chrono::Duration::days(7)).timestamp() as u64;
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,
email: email.into(),
is_superadmin,
exp,
iss: ISSUER.into(),
aud: AUDIENCE.into(),
refresh,
};
let header = Header {
alg: Algorithm::HS256,
..Default::default()
};
encode(
&Header::default(),
&header,
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|_| AppError::Unauthorized)
.map_err(|e| {
tracing::error!(error = %e, "JWT encode failed");
AppError::Unauthorized
})
}
pub fn decode_jwt(token: &str, secret: &str) -> Result<TutorClaims, AppError> {
decode::<TutorClaims>(
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::default(),
&validation,
)
.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)
}
// Axum extractor: pulls JWT from httpOnly cookie or Authorization: Bearer header
// Axum extractor: pulls Access JWT (not refresh) from httpOnly cookie or Authorization: Bearer header
impl<S> FromRequestParts<S> for TutorClaims
where
S: Send + Sync,
@@ -74,7 +108,7 @@ where
.to_string()
};
decode_jwt(&token, &app_state.jwt_secret)
decode_jwt(&token, &app_state.jwt_secret, false)
}
}
@@ -89,13 +123,16 @@ mod tests {
temp_env::with_var("JWT_SECRET", Some("testsecret_auth"), || {
let secret = "testsecret_auth";
// roundtrip
let token = encode_jwt(1, "test@example.com", true, secret).unwrap();
let claims = decode_jwt(&token, secret).unwrap();
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);
// rejection
assert!(decode_jwt("not.a.token", secret).is_err());
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

@@ -39,6 +39,10 @@ async fn main() {
let test_mode = false;
if test_mode {
// 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();
@@ -61,6 +65,18 @@ async fn main() {
.fallback_service(
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());
@@ -70,10 +86,38 @@ async fn main() {
.await
.expect("failed to bind");
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

@@ -1,7 +1,9 @@
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;
@@ -29,38 +31,114 @@ async fn login(
.fetch_optional(&state.pool)
.await?;
let (id, email, hash, is_superadmin) = tutor.ok_or(AppError::Unauthorized)?;
if !bcrypt::verify(&req.password, &hash).unwrap_or(false) {
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);
}
let token = auth::encode_jwt(id, &email, is_superadmin, &state.jwt_secret)?;
// 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 cookie = Cookie::build(("token", token.clone()))
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(cookie),
jar.add(access_cookie).add(refresh_cookie),
Json(json!({"is_superadmin": is_superadmin})),
))
}
async fn me(auth: auth::TutorClaims) -> impl axum::response::IntoResponse {
(
[("Cache-Control", "no-store")],
Json(json!({
"id": auth.sub,
"email": auth.email,
"is_superadmin": auth.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> {
@@ -81,6 +159,7 @@ pub fn router(test_mode: bool) -> Router<AppState> {
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))
}
@@ -88,11 +167,10 @@ pub fn router(test_mode: bool) -> Router<AppState> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::post_json;
use serde_json::json;
#[sqlx::test(migrations = "./migrations")]
async fn login_returns_superadmin_and_cookie(pool: sqlx::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")
@@ -120,37 +198,22 @@ mod tests {
let res = serde_json::from_slice::<Value>(&body).unwrap();
assert_eq!(res["is_superadmin"], false);
// Check Set-Cookie header
let cookie = headers.get("set-cookie").unwrap().to_str().unwrap();
assert!(cookie.contains("token="));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("SameSite=Strict"));
}
// 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=")));
#[sqlx::test(migrations = "./migrations")]
async fn login_wrong_password(pool: sqlx::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();
let state = AppState {
pool: pool.clone(),
jwt_secret: "testsecret".into(),
test_mode: true,
};
let app = crate::routes::build(state, true);
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

@@ -17,9 +17,9 @@ 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()
}

View File

@@ -38,7 +38,16 @@ 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 (?, ?, ?, ?)",

View File

@@ -40,7 +40,7 @@ pub async fn make_token(
.fetch_one(pool)
.await
.unwrap();
crate::auth::encode_jwt(row.0, email, row.1, secret).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.

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

@@ -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,19 +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",
"@types/node": "^22",
"@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",
"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"
}
}

945
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,11 +1,14 @@
import { browser } from '$app/environment';
import type {
Course, Tutor, Student, Room, Session, Slot, Attendance, Note
Course, Tutor, Student, Room, Session, Slot, Attendance, Note,
LayoutElement, SessionAttendance, CheckinInfo
} from './types';
import { auth } from './auth.svelte';
const BASE = '/api';
let isRefreshing = false;
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
...init,
@@ -17,6 +20,20 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
});
if (res.status === 401 && browser) {
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');
}
@@ -57,7 +74,7 @@ export const api = {
importStudents: (course_id: number, file: File) => {
const formData = new FormData();
formData.append('file', file);
return request<any>(`/admin/courses/${course_id}/students/import`, {
return request<{count: number}>(`/admin/courses/${course_id}/students/import`, {
method: 'POST',
body: formData
});
@@ -87,13 +104,13 @@ export const api = {
},
rooms: {
list: () => request<Room[]>('/admin/rooms'),
create: (name: string, layout: any[]) =>
create: (name: string, layout: LayoutElement[]) =>
request<Room>('/admin/rooms', {
method: 'POST',
body: JSON.stringify({ name, layout })
}),
get: (id: number) => request<Room>(`/admin/rooms/${id}`),
updateLayout: (id: number, layout: any[]) =>
updateLayout: (id: number, layout: LayoutElement[]) =>
request<Room>(`/admin/rooms/${id}/layout`, {
method: 'PUT',
body: JSON.stringify(layout)
@@ -106,7 +123,7 @@ export const api = {
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) =>
@@ -143,10 +160,10 @@ export const api = {
}
},
checkin: {
getInfo: (code: string) => request<any>(`/checkin/${code}`),
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

@@ -14,7 +14,13 @@ export const auth = {
async init() {
if (!browser || _initialized) return;
try {
const res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' });
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;
@@ -23,7 +29,7 @@ export const auth = {
_isSuperadmin = false;
_authenticated = false;
}
} catch (e) {
} catch (_e) {
_isSuperadmin = false;
_authenticated = false;
} finally {
@@ -40,7 +46,9 @@ export const auth = {
async logout() {
try {
await api.auth.logout();
} catch (e) {}
} catch (_e) {
console.error('logout failed', _e);
}
_isSuperadmin = false;
_authenticated = false;
if (browser) {

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

@@ -67,7 +67,7 @@
<!-- Navigation -->
<nav style="display:flex;flex-direction:column;gap:1px">
<div class="eyebrow" style="margin-bottom:6px">Navigation</div>
{#each navItems as item}
{#each navItems as item (item.id)}
{#if !item.superadmin || auth.isSuperadmin}
{@const active = isActive(item)}
<a

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

@@ -12,20 +12,25 @@
let course = $state<Course | null>(null);
const isLoginRoute = $derived($page.url.pathname === '/admin/login');
onMount(async () => {
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 (auth.initialized && !auth.authenticated) goto('/admin/login');
if (auth.initialized && !auth.authenticated && !isLoginRoute) goto('/admin/login');
});
const activePath = $derived($page.url.pathname);
@@ -38,10 +43,10 @@
courseName={course?.name ?? ''}
semester={course?.semester ?? ''}
>
{#snippet children()}
{@render children()}
{/snippet}
{@render children()}
</TutorShell>
{:else if isLoginRoute}
{@render children()}
{:else}
<div style="padding: 2rem; text-align: center;">Redirecting to login...</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

@@ -102,17 +102,17 @@
</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 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

@@ -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

@@ -25,28 +25,36 @@ async function globalSetup() {
});
if (!res.ok) throw new Error(`Login failed: ${res.status} ${await res.text()}`);
// Extract token from Set-Cookie header
const setCookie = res.headers.get('set-cookie');
const tokenMatch = setCookie?.match(/token=([^;]+)/);
const token = tokenMatch ? tokenMatch[1] : '';
// 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;
const { is_superadmin } = await res.json() as { is_superadmin: boolean };
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: [
{
name: 'token',
value: token,
domain: new URL(baseURL).hostname,
path: '/',
httpOnly: true,
secure: baseURL.startsWith('https'),
sameSite: 'Strict' as const,
}
],
cookies,
origins: [
{
origin: baseURL,

View File

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