11 Commits

Author SHA1 Message Date
d79c7ed08c added git worktrees to the plan 2026-05-04 17:26:24 +02:00
650f3456cb some planning and issue finding 2026-05-04 17:15:53 +02:00
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
49 changed files with 2276 additions and 603 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,13 +68,16 @@ 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
- name: Security audit
run: |
cargo install cargo-audit --locked
cargo audit --manifest-path backend/Cargo.toml
cd backend && cargo audit --ignore RUSTSEC-2023-0071
- name: Build frontend
run: pnpm --dir frontend build

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
@@ -70,7 +73,7 @@ jobs:
- name: Security audit
run: |
cargo install cargo-audit --locked
cargo audit --manifest-path backend/Cargo.toml
cd backend && cargo audit --ignore RUSTSEC-2023-0071
- name: Build frontend
run: pnpm --dir frontend build
@@ -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,56 +31,135 @@ 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() -> Router<AppState> {
let governor_conf = Arc::new(
GovernorConfigBuilder::default()
.per_second(12) // 1 request every 12 seconds = 5 per minute
.burst_size(5)
.finish()
.unwrap(),
);
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",
post(login).layer(GovernorLayer {
config: governor_conf,
}),
)
.route("/api/auth/login", login_route)
.route("/api/auth/refresh", post(refresh))
.route("/api/auth/me", get(me))
.route("/api/auth/logout", post(logout))
}
@@ -86,11 +167,10 @@ pub fn router() -> 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")
@@ -118,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,7 +17,7 @@ mod tutors;
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())

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,161 +0,0 @@
# Implementation Plan: Attendance Tracking Tool (Continuation)
**Objective:** Complete the backend API, scaffold the SvelteKit frontend, implement all frontend views based on the provided design handoff, and set up deployment manifests. This plan picks up from where the `.worktrees/feature-tutortool` workspace left off (Tasks 1-8 completed).
**Scope:**
- Complete backend APIs for attendance, notes, and exports.
- Serve SvelteKit SPA fallback via Axum.
- Scaffold SvelteKit frontend.
- Implement UI pages for Admin tools (Dashboard, Courses, Rooms, Sessions, Attendance, Notes, Export) and Student Check-in.
- Configure local development (Makefile, Docker Compose) and K8s manifests.
---
## 1. Backend Completion
### Task 9: Admin Attendance & Notes APIs
**Files to Create/Modify:**
- `backend/src/routes/attendance.rs`
- `backend/src/routes/notes.rs`
**Implementation Steps:**
- Add `POST /api/admin/slots/:id/attendance` (manual entry) and `DELETE /api/admin/slots/:slot_id/attendance/:student_id`.
- Add `GET /api/admin/sessions/:id/attendance` (per-week matrix) and `GET /api/admin/students/:id/attendance`.
- Add `PUT /api/admin/slots/:slot_id/notes/:student_id` (upsert note).
- Add `GET /api/admin/slots/:slot_id/notes` and `GET /api/admin/students/:id/notes`.
- Write corresponding unit tests.
### Task 10: Export API
**Files to Create/Modify:**
- `backend/src/routes/export.rs`
**Implementation Steps:**
- Add `GET /api/admin/export/session/:id/csv` and `/md` (merged per-session weekly attendance).
- Add `GET /api/admin/export/course/:id/csv` and `/md` (full course matrix with Bonus points calculation: +3 if unexcused absences <= 1).
- Add `GET /api/admin/backup` (using `VACUUM INTO '/tmp/backup-<timestamp>.sqlite'` then streaming as `application/octet-stream`).
- Ensure all endpoints verify `TutorClaims` and course access.
### Task 11: Static File Serving & Route Assembly
**Files to Modify:**
- `backend/src/main.rs`
- `backend/src/routes/mod.rs`
**Implementation Steps:**
- Merge `attendance`, `notes`, and `export` routers in `routes/mod.rs`.
- Configure `tower_http::services::ServeDir` in `main.rs` to serve the SvelteKit static build.
- Set up `ServeFile::new(format!("{static_dir}/index.html"))` as the SPA fallback service.
---
## 2. Frontend Development
### Task 12: Scaffold SvelteKit Frontend
**Files to Create/Modify:**
- `frontend/package.json`, `svelte.config.js`, `vite.config.ts`, `src/app.html`, `src/lib/types.ts`, `src/lib/api.ts`, `src/lib/auth.ts`
**Implementation Steps:**
- Initialize SvelteKit with `@sveltejs/adapter-static` configured for SPA fallback.
- Setup Vite proxy `/api` -> `http://localhost:3000`.
- Create TypeScript types mirroring the backend database models.
- Implement API client fetch wrapper (`api.ts`).
- Set up Svelte store for JWT auth (`auth.ts`).
### Task 13: Login Page & Admin Auth Guard
**Files to Create/Modify:**
- `frontend/src/routes/login/+page.svelte`
- `frontend/src/routes/admin/+layout.svelte`
**Implementation Steps:**
- Implement the tutor login form and API integration.
- Protect `/admin` routes using an `onMount` check redirecting unauthenticated users to `/login`.
### Task 14: Dashboard & Slot Management
**Files to Create/Modify:**
- `frontend/src/routes/admin/+page.svelte`
**Implementation Steps:**
- Display all sessions/slots.
- Implement toggles for slot status (`closed`, `open`, `locked`).
- Display check-in link and copy button when a slot is `open`.
### Task 15: Courses & Students UI
**Files to Create/Modify:**
- `frontend/src/routes/admin/courses/+page.svelte`
**Implementation Steps:**
- List courses and forms to create new courses.
- Per-course student management: list students, add individual student, import from CSV, and delete students.
### Task 16: Room Layout Editor
**Files to Create/Modify:**
- `frontend/src/lib/RoomCanvas.svelte`
- `frontend/src/routes/admin/rooms/+page.svelte`
**Implementation Steps:**
- Implement SVG-based `RoomCanvas.svelte` supporting interactive (draggable/editable) mode, student check-in mode, and tutor notes mode.
- Build room management UI: list rooms, create rooms, and edit JSON room layouts visually.
### Task 17: Sessions & Slots UI
**Files to Create/Modify:**
- `frontend/src/routes/admin/sessions/+page.svelte`
**Implementation Steps:**
- Form to create sessions (course, week_nr, date).
- Form to add slots within a session (room, tutor, start_time, end_time).
### Task 18: Student Check-in Page
**Files to Create/Modify:**
- `frontend/src/routes/s/[code]/+page.svelte`
**Implementation Steps:**
- Fetch slot info. If no identity cookie exists, show name dropdown filtered to the course.
- Display `RoomCanvas` indicating free/occupied seats.
- Handle FCFS seat locking API calls (`POST /api/checkin`) and update UI.
- Support read-only mode for locked/closed slots.
### Task 19: Attendance & Notes UI
**Files to Create/Modify:**
- `frontend/src/routes/admin/attendance/+page.svelte`
- `frontend/src/routes/admin/notes/+page.svelte`
**Implementation Steps:**
- Build matrix tables (per-week and per-student) for manual attendance marking/removal.
- Build notes UI utilizing `RoomCanvas` for clicking on seats to leave inline text notes.
### Task 20: Export UI
**Files to Create/Modify:**
- `frontend/src/routes/admin/export/+page.svelte`
**Implementation Steps:**
- Provide buttons to download weekly CSV/Markdown, full course matrix, and SQLite backup directly from the admin interface.
---
## 3. DevOps & Deployment
### Task 21: Local Dev Environment
**Files to Create/Modify:**
- `Makefile`
- `docker-compose.yml`
**Implementation Steps:**
- Write `Makefile` with commands: `dev`, `dev-backend`, `dev-frontend`, `test`, `build`, `compose-up`.
- Create `docker-compose.yml` for testing the production image locally using SQLite mounted via volume.
### Task 22: Dockerfile & K8s Manifests
**Files to Create/Modify:**
- `Dockerfile`
- `k8s/deployment.yaml`, `k8s/service.yaml`, `k8s/ingress.yaml`, `k8s/pvc.yaml`, `k8s/cronjob.yaml`
**Implementation Steps:**
- Write a 3-stage `Dockerfile` (frontend build, backend build, alpine + sqlite runtime).
- Write `pvc.yaml` for SQLite persistent storage.
- Write `deployment.yaml`, `service.yaml`, and `ingress.yaml` (`tutor.puchstein.dev`).
- Write `cronjob.yaml` running at 3 AM daily, executing `sqlite3 /data/attendance.db "VACUUM INTO '/data/backup-$(date +%F).sqlite'"` and pruning files older than 7 days.
---
## Verification Strategy
1. **Unit Tests:** Execute `cargo test` in `backend/` to verify all new endpoints (Tasks 9-11).
2. **End-to-End Test:** Start `make dev` and manually verify all critical paths: tutor login, session/slot creation, student check-in with cookie persistence, FCFS seat collision handling, manual attendance, and exporting.
3. **Deployment Test:** Run `make compose-up` to ensure the built Docker container operates as expected, serving Svelte SPA fallback via Axum properly.

View File

@@ -1,39 +0,0 @@
# Demo Preparation & Seed Data Plan
## Objective
Create an isolated, reproducible demo environment for presenting TutorTool on a Surface Pro 5 (Arch Linux) using Docker. This includes a robust set of seed data to simulate a live application state, which can also be utilized for local end-to-end testing.
## Key Files & Context
- **Environment**: `docker-compose.yml` (existing)
- **Database**: SQLite (`data/attendance.db`)
- **New Files**:
- `backend/migrations/demo_seed.sql`: A standalone SQL script containing isolated test data.
- **Modified Files**:
- `Makefile`: Update to include a `seed-demo` target for easy execution.
## Implementation Steps
### 1. Workspace Isolation via Git Worktree
When implementing this plan, the Gemini CLI will automatically utilize its Git worktree feature to spawn a new isolated workspace (e.g., `feature/demo-seed`). This ensures the backend tooling modifications do not interfere with the `frontend-design-overhaul` worktree or the main branch.
### 2. Create the Seed Data Script (`backend/migrations/demo_seed.sql`)
Create a SQL script that safely injects realistic demo data. It will use `INSERT OR IGNORE` or handle conflicts to ensure it can be run cleanly for both demo and testing purposes.
- **Admin/Tutor Account**:
- Name: "Demo Admin"
- Email: `admin@tutortool.com`
- Password Hash: A pre-calculated bcrypt hash for the password `admin`.
- **Course**: "Demo Course 101" (Semester: "Current").
- **Room Layout**: A valid JSON SVG layout representing a small classroom with a few tables and seats.
- **Students**: Generate ~10 distinct student names linked to the course.
- **Session & Slot**: Create a session for the current date and an "open" slot linked to the demo room, ensuring the check-in feature can be demonstrated immediately without setup.
### 3. Update Makefile
Add a `seed-demo` target to the existing `Makefile`.
- The target will execute the SQLite CLI to run `demo_seed.sql` against the local development database defined by the `DATABASE_URL` environment variable (defaulting to `sqlite:./dev.db` for local dev).
### 4. Demo Run Guide
Provide a short set of instructions on how to start the environment using `docker-compose up` and the new seed command on the Surface Pro 5.
## Verification & Testing
- **Execution**: Run `make seed-demo` against a fresh SQLite database to ensure no foreign key or syntax errors occur.
- **Authentication**: Verify that logging in with `admin@tutortool.com` / `admin` succeeds against the seeded database.

View File

@@ -1,13 +0,0 @@
# Objective
Fix the Playwright MCP configuration in Gemini CLI by aligning it with the working Claude Code configuration.
# Key Files & Context
- `.gemini/settings.json`: This file currently contains the incorrect configuration pointing to `/opt/google/chrome/chrome`.
# Implementation Steps
1. Update `.gemini/settings.json` to modify the `args` array for the `playwright` mcpServer.
2. Remove the `--browser` and `chrome` arguments.
3. Update `--executable-path` to point to `/home/mpuchstein/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome` (the working binary used by Claude).
# Verification & Testing
After updating the configuration, the user will need to restart the Gemini CLI session to apply the changes and verify that the Playwright MCP tools function correctly.

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

@@ -1,56 +0,0 @@
# Superadmin CRUD Implementation Plan
**Objective:** Implement a superadmin role to manage courses and tutors, ensuring only authorized users can perform system-wide administrative actions. This feature will be developed in an isolated git worktree.
## Key Context & Decisions
- **Role Strategy:** A new `is_superadmin` boolean column will be added to the `tutors` database table.
- **UI Structure:** A dedicated `/admin/tutors` page will handle tutor management. Course management will remain on `/admin/courses` but will be enhanced with superadmin-only actions (e.g., assigning tutors to courses).
- **Workspace:** Development will be done in `.worktrees/feature-superadmin-crud`.
## Implementation Steps
### 1. Workspace Isolation via Git Worktree
- Create a new git worktree: `git worktree add .worktrees/feature-superadmin-crud -b feature-superadmin-crud`
- All subsequent steps will be performed inside this isolated workspace.
### 2. Database & Models
- Create migration `backend/migrations/002_add_superadmin.sql` to add `is_superadmin BOOLEAN NOT NULL DEFAULT 0` to the `tutors` table.
- Update `backend/demo/demo_seed.sql` to set the default `admin@tutortool.com` as a superadmin (`is_superadmin = 1`).
- Update `backend/src/models.rs` to include `is_superadmin: bool` in the `Tutor` struct.
- Add `CreateTutor` and `TutorResponse` structs to `backend/src/models.rs`.
### 3. Auth & Core Backend
- Modify `backend/src/auth.rs` to include `is_superadmin: bool` in `TutorClaims`. This allows auth guards to check permissions efficiently.
- Update `backend/src/routes/auth_routes.rs` login handler to fetch `is_superadmin` and encode it in the JWT.
- Add a helper function to verify superadmin access to reject unauthorized requests.
### 4. Tutors API
- Create `backend/src/routes/tutors.rs` with endpoints:
- `GET /api/admin/tutors` (list all tutors)
- `POST /api/admin/tutors` (create a tutor, hashing their password)
- `DELETE /api/admin/tutors/:id` (delete a tutor)
- Merge these routes in `backend/src/routes/mod.rs`.
### 5. Course Assignments API
- Modify `backend/src/routes/courses.rs`:
- Enhance `GET /api/admin/courses` to return ALL courses if `claims.is_superadmin` is true, otherwise only return assigned courses.
- Restrict `POST /api/admin/courses` to superadmins only.
- Add `POST /api/admin/courses/:id/tutors` to assign a tutor to a course (superadmin only).
- Add `DELETE /api/admin/courses/:id/tutors/:tutor_id` to remove a tutor from a course (superadmin only).
- Add `GET /api/admin/courses/:id/tutors` to list tutors assigned to a course.
### 6. Frontend Auth & API Client
- Update `frontend/src/lib/types.ts` to include `Tutor` and the new `is_superadmin` flag in token payload or state.
- Add the new endpoints to `frontend/src/lib/api.ts` under `api.admin.tutors` and enhance `api.admin.courses`.
### 7. Frontend UI: Tutors Management
- Update `frontend/src/lib/components/TutorShell.svelte` to conditionally render a "Tutor:innen" link in the sidebar if the user is a superadmin.
- Create `frontend/src/routes/admin/tutors/+page.svelte` following the paper-bg design system. Include a list of tutors and a form to add a new tutor.
### 8. Frontend UI: Courses Enhancements
- Modify `frontend/src/routes/admin/courses/+page.svelte` to show a "Tutor:innen zuweisen" (Assign Tutors) section for each course if the logged-in user is a superadmin.
- Restrict the course creation form to superadmins only.
## Verification & Testing
- Run `cargo test` in the backend to ensure existing tests pass and new route isolation works.
- Perform a manual end-to-end test using the `make dev` script in the new worktree to verify the UI.

View File

@@ -7,12 +7,22 @@ metadata:
{{- include "tutortool.labels" . | nindent 4 }}
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
jobTemplate:
spec:
activeDeadlineSeconds: 900
template:
spec:
restartPolicy: OnFailure
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
{{- include "tutortool.selectorLabels" . | nindent 22 }}
topologyKey: kubernetes.io/hostname
containers:
- name: backup
image: alpine:latest

View File

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

View File

@@ -0,0 +1,40 @@
# Issues Discovered - 2026-05-04
This document summarizes the issues found during the Playwright exploration session on 2026-05-04.
## 1. JSON Parse Error when adding Tutors to Courses
- **Location**: `frontend/src/routes/admin/courses/+page.svelte` (triggered by `api.admin.courses.addTutor`)
- **Symptoms**: Alert box shows `SyntaxError: Failed to execute 'json' on 'Response': Unexpected end of JSON input`.
- **Root Cause**: The backend `POST /api/admin/courses/:id/tutors` handler likely returns a `200 OK` with an empty body. The frontend `api.ts` wrapper attempts to call `.json()` on every response, which fails on empty bodies.
- **Evidence**: Network request #48 in Playwright session showed `status: 200` but `content-length: 0`.
## 2. Attendance Count Missing on Student Check-in Page
- **Location**: `frontend/src/routes/s/[code]/+page.svelte`
- **Symptoms**: The "Anwesende" count shows `0 / 11` even after a successful check-in or when other students are present.
- **Root Cause**: The `loadInfo` function fetches the data but never assigns `res.attendances` to the local `attendances` state variable.
- **Evidence**: `loadInfo` contains `const checkinAttendances = res.attendances ?? [];` but lacks `attendances = checkinAttendances;`.
## 3. Seat Map Empty in Live View
- **Location**: `frontend/src/routes/admin/live/[slotId]/+page.svelte`
- **Symptoms**: All seats show as "frei" in the tutor's live view, even when students are checked in and assigned to seats.
- **Root Cause**: The `SeatMap` component is instantiated without passing `assignments` or `students` props.
- **Evidence**: `<SeatMap variant="tutor" scale={0.78} />` is used without other props in the source code.
## 4. Room Editor Element Selection Broken
- **Location**: `frontend/src/lib/RoomCanvas.svelte`
- **Symptoms**: Clicking an element in the Room Layout Editor does not select it (sidebar continues to show "Element auswählen").
- **Root Cause**: In `handleMouseDown`, if `editable` is true, the function returns early without calling `onElementClick`.
- **Evidence**:
```typescript
function handleMouseDown(e: MouseEvent, el: LayoutElement) {
if (!editable) {
onElementClick?.(el);
return;
}
draggingId = el.id;
// ...
}
```
## 5. Potential UI/UX: Dashboard Slot Status
- **Observation**: Dashboard sometimes shows "Anwesenheit offen" but "Alle Slots 1" and "Abgeschlossene Slots 0". It's a bit confusing if there is only one slot ever. (Minor)

View File

@@ -0,0 +1,263 @@
# TutorTool — Unified Bug Fixes, Tutor Lifecycle, Room Editor Refactor
## Context
A Playwright exploration session on **2026-05-04** (`docs/issues_discovered_20260504.md`) surfaced four production frontend bugs on top of an existing security backlog (RUSTSEC-2023-0071) and a half-finished Room Editor refactor. Three reference plans already exist in `conductor/`:
- `conductor/room-editor-refactor-core.md` — pixel→grid migration + editor robustification
- `conductor/room-editor-refactor-visualization.md` — replace hardcoded `SeatMap.svelte` with dynamic `RoomCanvas`
- `conductor/unified-refactor-and-fixes.md` — already merges the above with security/quick fixes (RUSTSEC, JSON parse, check-in typing, admin logout)
This plan **supersedes** `conductor/unified-refactor-and-fixes.md`, folds in two newly verified items:
1. **Admin/tutor deletion is opaquely broken.** `DELETE /api/admin/tutors/{id}` exists (`backend/src/routes/tutors.rs:74-94`) but the three FK references — `tutor_courses.tutor_id`, `slots.tutor_id`, `notes.tutor_id` (`backend/migrations/001_initial.sql:15,44,65`) — declare no `ON DELETE` clause, so SQLite RESTRICT raises a generic FK error → 500 → `alert("internal error")`.
2. **Issue #4 in the discovered-issues doc has its condition inverted.** `frontend/src/lib/RoomCanvas.svelte:30-38` is `if (!editable) { onElementClick?.(el); return; }`, so click-to-select fails **in edit mode** (the opposite of what the doc says). Fix: in edit mode, also fire `onElementClick` before initiating the drag.
The intended outcome is one continuous batch of work that ships: secure auth (logout + RUSTSEC patch), correct frontend data flow, a real tutor lifecycle (deactivate + delete with safety), and a unified, dynamic room renderer that fixes the silently-broken student check-in.
> **Memory housekeeping (post-approval):** the SLM memory `1728144c207346e2` ("admin CRUD enforces a permanent restriction: no tutors can be deleted") is **stale and contradicted by the code**. Update or remove it after this plan is approved.
---
## Phase 0 — Git Workflow
### 0.1 Create a worktree
From your main clone (assume it lives at `~/tutortool`):
```sh
git worktree add ../tutortool-unified main
cd ../tutortool-unified
git switch -c feature/unified-fixes-room-editor
```
All implementation work happens in `../tutortool-unified`.
The original clone stays clean on `main`.
### 0.2 Commit discipline
- One logical commit per Phase/sub-phase, e.g.:
- `feat(security): fix RUSTSEC-2023-0071 via aws_lc_rs`
- `fix(api): handle empty 200 body + fix check-in typing`
- `feat(tutors): add is_active flag, deactivate + safe delete`
- `feat(rooms): pixel→grid migration + layout validators`
- `feat(canvas): fix drag/resize, click-to-select in edit mode`
- `feat(viz): replace SeatMap with dynamic RoomCanvas`
- `make lint && make test` must pass **before** each commit.
Gate with a pre-commit hook if you like:
```sh
# .git/hooks/pre-commit
#!/bin/sh
cd backend && cargo clippy --all-targets -- -D warnings && cargo test
```
### 0.3 Open the PR
Once all phases are done and CI is green:
```sh
git push -u origin feature/unified-fixes-room-editor
# then open a PR against main via Gitea/GitHub UI
# title: "Unified fixes, tutor lifecycle, room editor refactor"
# body: link this plan doc, list phases, reference Playwright spec results
```
Merge strategy: **squash-merge** each phase-branch if you split work, or a single **merge commit** if working on one branch (keeps phase history readable).
### 0.4 Post-merge cleanup
```sh
cd ~/tutortool # back to main clone
git pull # fast-forward to merged state
git worktree remove ../tutortool-unified
git branch -d feature/unified-fixes-room-editor
```
### 0.5 Verification gate before merge
PR must have:
- [ ] `make test` passes (all backend + new Phase 2.4/3.2/3.7 tests)
- [ ] `make lint` passes (zero Clippy warnings, zero TS errors)
- [ ] `sqlx migrate run` clean on fresh DB (migrations 003 + 004)
- [ ] All new Playwright specs green (`rooms`, `checkin-dynamic`, `admin-live-dynamic`, `admin-tutors`, `admin-rooms-delete`)
- [ ] `cargo audit` clean (no RUSTSEC-2023-0071)
## Phase 1 — Security & Quick Fixes
### 1.1 RUSTSEC-2023-0071 (Marvin Attack)
- `backend/Cargo.toml` — set `jsonwebtoken` to `features = ["aws_lc_rs"]`.
- `backend/audit.toml` — remove `ignore = ["RUSTSEC-2023-0071"]`.
- `.gitea/workflows/ci.yml` and `.gitea/workflows/release.yml` — drop `--ignore RUSTSEC-2023-0071`.
### 1.2 JSON parse error on empty 200 body
- `frontend/src/lib/api.ts:46` — currently only 204 is short-circuited. Extend the empty-body branch to also handle 200-with-empty-body (probe `content-length === '0'` or fall through `await res.text()` and return `{} as T` if empty). Triggers from `assignTutor`, `unassignTutor`, etc.
### 1.3 Check-in API typing
- `frontend/src/lib/api.ts` — `checkin.post` currently typed `Promise<Attendance>`; backend returns `{ok: true}`. Change to `Promise<{ok: boolean}>`.
- `frontend/src/routes/s/[code]/+page.svelte:82` — drop the local-state assignment from the response; rely on the subsequent `loadInfo()` to populate `myAttendance`.
### 1.4 Attendance count not assigned (issues doc #2)
- `frontend/src/routes/s/[code]/+page.svelte` `loadInfo` — assign `attendances = checkinAttendances` so the "Anwesende N / M" counter updates.
### 1.5 Admin logout UI
- `frontend/src/lib/components/TutorShell.svelte` (where the actual sidebar nav lives — `frontend/src/routes/admin/+layout.svelte` only renders the shell) — add an "Abmelden" entry at the bottom of the nav. On click: `await api.auth.logout()` → `auth.logout()` → `goto('/admin/login')`. Re-export `auth` and `api` if needed.
---
## Phase 2 — Tutor Lifecycle: Deactivate **and** Delete
The user wants **both** soft-deactivate (preserve history, hide from pickers) **and** real hard-delete (with safety pre-check).
### 2.1 Schema migration
- New file `backend/migrations/004_tutor_is_active.sql`:
```sql
ALTER TABLE tutors ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT 1;
```
No backfill needed; existing rows default to active.
### 2.2 Backend changes
**File:** `backend/src/routes/tutors.rs`
- **`Tutor` model** (`backend/src/models.rs:11-17`) — add `is_active: bool`.
- **`list_tutors`** — include `is_active` in SELECT and JSON output. Do **not** filter; the admin needs to see inactive tutors to reactivate them.
- **New: `set_tutor_active(id, {is_active: bool})`** — `PATCH /api/admin/tutors/{id}/active` body `{is_active: bool}`. Superadmin-only. Forbid self-deactivation (mirror the self-delete guard at `tutors.rs:84-86` — return `AppError::Conflict("cannot deactivate yourself")`).
- **`delete_tutor`** (existing handler) — before the `DELETE FROM tutors`, run three `SELECT COUNT(*)` queries inside a single connection (no need for an explicit tx; SQLite SELECTs see committed state):
- `tutor_courses WHERE tutor_id = ?`
- `slots WHERE tutor_id = ?`
- `notes WHERE tutor_id = ?`
If any non-zero, return `AppError::Conflict(format!("Tutor:in hat noch {c} Kurszuordnung(en), {s} Slot(s) und {n} Notiz(en). Bitte zuerst entfernen oder deaktivieren."))`.
`AppError::Conflict` already maps to 409 with `{"error": msg}` (`backend/src/error.rs`).
- **Auth login** (`backend/src/routes/auth_routes.rs:22-96`) — reject inactive tutors with `AppError::Unauthorized` (same response shape as wrong-password to avoid info leakage). Add this check after password verification.
- **Tutor pickers** — grep for `SELECT … FROM tutors` outside of `list_tutors` and `auth_routes.rs`. Likely sites: `backend/src/routes/courses.rs` (tutor assignment list), `backend/src/routes/sessions.rs` or `slots` creation flow. Each must add `WHERE is_active = 1`.
### 2.3 Frontend changes
**File:** `frontend/src/routes/admin/tutors/+page.svelte`
- Show three status pills: `Superadmin` / `Tutor:in` / `Inaktiv` (combine `is_superadmin` + `is_active`).
- Add a per-row "Deaktivieren" button (or "Aktivieren" if already inactive) that calls `api.admin.tutors.setActive(id, !is_active)`.
- Keep the existing "Löschen" button — error message from 409 already surfaces via the existing `catch (err) { alert(err.message) }` because `request<T>` extracts `error.error` (`api.ts:42-43`). Optionally: replace alert with an inline error region above the table for better UX.
- **API client** `frontend/src/lib/api.ts` — add `api.admin.tutors.setActive(id, is_active)` calling `PATCH /admin/tutors/{id}/active`.
### 2.4 Tests (`backend/src/routes/tutors.rs` `#[cfg(test)]` block)
Pattern after `backend/src/routes/rooms.rs:184-322` and use `backend/src/test_helpers.rs`.
- `delete_tutor_with_no_refs_succeeds` → 204, row gone.
- `delete_tutor_with_course_assignment_returns_409` → body contains `Kurszuordnung`.
- `delete_tutor_with_slot_returns_409` → body contains `Slot`.
- `delete_tutor_with_note_returns_409` → body contains `Notiz`.
- `delete_self_returns_409` (existing branch coverage).
- `set_active_false_then_login_fails` — deactivate, attempt login, expect 401.
- `set_active_false_hides_from_pickers` — verify any tutor-picker endpoint doesn't list inactive tutors.
- `cannot_deactivate_self_returns_409`.
---
## Phase 3 — Room Editor Core
Reference: `conductor/room-editor-refactor-core.md`. One-liner per task:
- **3.1 Pixel→grid migration** — new `backend/migrations/003_normalize_room_layout_units.sql`: parse `layout_json` per row; if any of x/y/w/h > 50, divide all four by 40 and rewrite. Update `backend/demo/demo_seed.sql:16-41` to grid units. Update any `rooms.rs` tests asserting pixel-scale numbers.
- **Note on numbering:** this is migration `003`; tutor `is_active` is `004`. Apply order matters because `003` only touches `rooms`, `004` only touches `tutors` — no cross-dependency.
- **3.2 Additive layout validators** — `backend/src/routes/rooms.rs:18-69`: add `MAX_CANVAS = 100` upper-bound + 0.5-step multiple checks; one test per validator.
- **3.3 RoomCanvas drag/resize hardening** — `frontend/src/lib/RoomCanvas.svelte`: bind `mousemove`/`mouseup` to `window` while dragging (currently bound to SVG only at lines 69-71); build resize handles + state from scratch; default snap step 0.5 (configurable via `snapStep` prop).
- **3.4 Fix click-to-select in edit mode (issues #4 corrected)** — `frontend/src/lib/RoomCanvas.svelte:30-38`: when `editable`, still call `onElementClick?.(el)` (so the parent updates `selectedId`) before starting the drag. Do **not** early-return.
- **3.5 Editor UI improvements** — `frontend/src/routes/admin/rooms/[roomId]/+page.svelte`: add X/Y `step="0.5"` numeric inputs, "+ Gap" button, "Snap to Grid" toggle, "Duplicate element" (UUID + 1-unit offset), inline error region for `saveLayout` failures (currently only `console.error`).
- **3.6 Robust seat labelling** — same file: replace the brittle `(filter + 1)` next-label with `max(existingLabels) + 1`.
- **3.7 Room and Element deletion**
- **Element deletion** is already wired in the editor sidebar (`+page.svelte:101`); validate it still works post-migration.
- **Room deletion (new)**:
- Backend: `DELETE /api/admin/rooms/{id}` in `backend/src/routes/rooms.rs`. Pre-check `SELECT COUNT(*) FROM slots WHERE room_id = ?`; if non-zero, 409 with `format!("Raum hat noch {n} Slot(s). Bitte zuerst entfernen.")`. Otherwise plain DELETE.
- Frontend: `frontend/src/routes/admin/rooms/+page.svelte` — add per-row "Löschen" button with `confirm()` dialog; error message surfaces via the existing alert path.
- API client: `api.admin.rooms.delete(id)`.
- Tests: `delete_room_with_no_slots_succeeds`, `delete_room_with_slot_returns_409`.
---
## Phase 4 — Unified Visualization (Dynamic RoomCanvas)
Reference: `conductor/room-editor-refactor-visualization.md`. One-liner per task:
- **4.1 Extend RoomCanvas** — `frontend/src/lib/RoomCanvas.svelte`: add `clickable: boolean = false` prop (orthogonal to 3.4 — `clickable` enables `onElementClick` in **read-only** mode); replace fixed `width="800" height="600"` (line 65) with computed `viewBox` + `preserveAspectRatio="xMidYMid meet"` + CSS `width:100%;height:auto`; verify the editor route still bounds the SVG (wrap with `style="width:800px"` if needed); apply seat/table styling from the design handoff (rounded tables, occupied/mine colour states, label positioning).
- **4.2 Backend: ship layout with attendance** — `backend/src/routes/attendance.rs:62-105` + `backend/src/models.rs`: extend `SessionAttendance` with per-slot `layout: Option<Vec<LayoutElement>>` to avoid an N+1.
- **4.3 Admin live view migration** — `frontend/src/routes/admin/live/[slotId]/+page.svelte:161`: replace `<SeatMap variant="tutor" scale={0.78} />` with `<RoomCanvas elements={slot.layout ?? []} clickable occupiedSeatIds={…} studentNames={…} onElementClick={handleSeatClick} />`. Implement `handleSeatClick(seatId)` mapping seat→student via `attendances.find(a => a.seat_id === seatId)?.student_id` → `selectedStudentId` (drives the existing note editor).
- **4.4 Student check-in migration (4 sites)** — `frontend/src/routes/s/[code]/+page.svelte` lines 210, 248, 316, 368: read `res.layout` in `loadInfo` (`let layout = $state<LayoutElement[]>([])`); replace each call. Lines 210/316 (seat-pick): `clickable` + `onElementClick={selectSeat}`. Lines 248/368 (confirmed): read-only with `mySeatId={myAttendance?.seat_id ?? null}`. Add `const occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(Boolean) as string[])`.
- **4.5 Delete `frontend/src/lib/components/SeatMap.svelte`** — only after `grep -rn "SeatMap" frontend/src/` returns zero hits and Playwright passes.
---
## Verification
### Automated
- `make test` — backend suite passes, including:
- Phase 2.4 tutor delete + activate tests
- Phase 3.2 layout validator tests
- Phase 3.7 room delete tests
- `sqlx migrate run` against a clean DB — migrations 003 and 004 apply cleanly.
- `make lint` — zero warnings (per the project's mandate).
- `frontend/tests/rooms.spec.ts` _(new)_ — create room, drag/snap, save/reload, assert coords preserved.
- `frontend/tests/checkin-dynamic.spec.ts` _(new)_ — custom layout, click seat, `POST /api/checkin` succeeds, seat turns green.
- `frontend/tests/admin-live-dynamic.spec.ts` _(new)_ — student appears on correct seat in tutor live view.
- `frontend/tests/admin-tutors.spec.ts` _(new)_ — (i) delete tutor with no refs → row disappears; (ii) delete tutor attached to a course → red error mentions `Kurszuordnung`; unassign → retry succeeds; (iii) deactivate → tutor cannot log in; (iv) reactivate → tutor can log in again.
- `frontend/tests/admin-rooms-delete.spec.ts` _(new)_ — delete unused room succeeds; delete room attached to a slot → 409 message visible.
### Manual
1. `make seed-demo`. Open `Admin → Rooms → Room A` — all elements at sensible grid positions (no values > 50).
2. Drag and resize an element; release outside the SVG; confirm no stranded drag.
3. Click an element in **edit mode** — sidebar populates (issue #4 regression).
4. Add a Gap; toggle Snap; Duplicate an element; trigger a deliberate save error and confirm the inline message.
5. Create a U-shaped room; attach to a session/slot.
6. Open the student check-in link on a mobile viewport — U-shape renders, seat-click checks in, seat turns green.
7. Open tutor Live View — student name on correct seat; click opens note editor.
8. **Tutor lifecycle:** as superadmin, create a tutor "Test", attach to a course → click `Löschen` → red message lists `1 Kurszuordnung`. Click `Deaktivieren` → status pill flips to `Inaktiv`; attempt to log in as that tutor → fails. Click `Aktivieren` → login succeeds again. Unassign from course → click `Löschen` → succeeds.
9. **Room lifecycle:** delete a room with no slots → row disappears. Delete a room attached to a slot → 409 message visible.
10. Click `Abmelden` in admin sidebar → redirect to `/admin/login`; refresh → still logged out.
11. `cargo audit` (without the ignore flag) — no RUSTSEC-2023-0071 finding.
---
## Critical files
- `backend/Cargo.toml`
- `backend/audit.toml`
- `.gitea/workflows/ci.yml`, `.gitea/workflows/release.yml`
- `backend/migrations/003_normalize_room_layout_units.sql` _(new)_
- `backend/migrations/004_tutor_is_active.sql` _(new)_
- `backend/demo/demo_seed.sql`
- `backend/src/models.rs`
- `backend/src/routes/tutors.rs`
- `backend/src/routes/rooms.rs`
- `backend/src/routes/attendance.rs`
- `backend/src/routes/auth_routes.rs`
- `backend/src/routes/courses.rs` (tutor picker filter)
- `backend/src/routes/sessions.rs` (tutor picker filter)
- `frontend/src/lib/api.ts`
- `frontend/src/lib/types.ts`
- `frontend/src/lib/RoomCanvas.svelte`
- `frontend/src/lib/components/SeatMap.svelte` _(delete in Phase 4.5)_
- `frontend/src/lib/components/TutorShell.svelte` (logout button)
- `frontend/src/routes/admin/tutors/+page.svelte`
- `frontend/src/routes/admin/rooms/+page.svelte`
- `frontend/src/routes/admin/rooms/[roomId]/+page.svelte`
- `frontend/src/routes/admin/live/[slotId]/+page.svelte`
- `frontend/src/routes/s/[code]/+page.svelte`
---
## Post-implementation memory hygiene (do after the plan ships)
- Update SLM memory `1728144c207346e2` to reflect the new lifecycle (deactivate + safe delete) instead of the stale "no tutors can be deleted" claim.
- Store a new SLM memory capturing the deletion-policy decision (deactivate-or-block-with-409) so future sessions don't re-litigate it.

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",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.59.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^22",
"@sveltejs/adapter-static": "^3",
"@sveltejs/kit": "^2",
"@sveltejs/vite-plugin-svelte": "^4",
"@typescript/native-preview": "^7.0.0-dev",
"svelte": "5.55.5",
"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",
"vite": "^8"
"typescript-eslint": "^8.59.1",
"vite": "^8.0.10"
}
}

1009
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"
}