5 Commits

Author SHA1 Message Date
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
38 changed files with 1724 additions and 214 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,38 +31,114 @@ async fn login(
.fetch_optional(&state.pool)
.await?;
let (id, email, hash, is_superadmin) = tutor.ok_or(AppError::Unauthorized)?;
if !bcrypt::verify(&req.password, &hash).unwrap_or(false) {
let (id, _email, hash, is_superadmin) = tutor.ok_or(AppError::Unauthorized)?;
let mut rehash_needed = false;
let mut authed = false;
// Try Argon2 first (modern hashes start with $argon2id$)
if hash.starts_with("$argon2id$") {
if let Ok(parsed_hash) = PasswordHash::new(&hash)
&& argon2::Argon2::default()
.verify_password(req.password.as_bytes(), &parsed_hash)
.is_ok()
{
authed = true;
}
} else {
// Fallback to bcrypt for legacy hashes
if bcrypt::verify(&req.password, &hash).unwrap_or(false) {
authed = true;
rehash_needed = true;
}
}
if !authed {
return Err(AppError::Unauthorized);
}
let token = auth::encode_jwt(id, &email, is_superadmin, &state.jwt_secret)?;
// Lazy rehash to Argon2 if we used bcrypt
if rehash_needed {
let salt = SaltString::generate(&mut rand::thread_rng());
if let Ok(new_hash) = argon2::Argon2::default()
.hash_password(req.password.as_bytes(), &salt)
.map(|h| h.to_string())
{
let _ = sqlx::query("UPDATE tutors SET password_hash = ? WHERE id = ?")
.bind(new_hash)
.bind(id)
.execute(&state.pool)
.await;
}
}
let cookie = Cookie::build(("token", token.clone()))
let access_token = auth::encode_jwt(id, is_superadmin, &state.jwt_secret, false)?;
let refresh_token = auth::encode_jwt(id, is_superadmin, &state.jwt_secret, true)?;
let access_cookie = Cookie::build(("token", access_token))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.secure(!state.test_mode)
.build();
let refresh_cookie = Cookie::build(("refresh_token", refresh_token))
.path("/api/auth/refresh")
.http_only(true)
.same_site(SameSite::Strict)
.secure(!state.test_mode)
.build();
Ok((
jar.add(cookie),
jar.add(access_cookie).add(refresh_cookie),
Json(json!({"is_superadmin": is_superadmin})),
))
}
async fn me(auth: auth::TutorClaims) -> impl axum::response::IntoResponse {
(
[("Cache-Control", "no-store")],
Json(json!({
"id": auth.sub,
"email": auth.email,
"is_superadmin": auth.is_superadmin
})),
)
async fn refresh(
State(state): State<AppState>,
jar: CookieJar,
) -> Result<(CookieJar, StatusCode), AppError> {
let refresh_token = jar
.get("refresh_token")
.map(|c| c.value().to_string())
.ok_or(AppError::Unauthorized)?;
let claims = auth::decode_jwt(&refresh_token, &state.jwt_secret, true)?;
// Issue new access token
let access_token =
auth::encode_jwt(claims.sub, claims.is_superadmin, &state.jwt_secret, false)?;
let access_cookie = Cookie::build(("token", access_token))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.secure(!state.test_mode)
.build();
Ok((jar.add(access_cookie), StatusCode::OK))
}
async fn me(
auth: auth::TutorClaims,
State(pool): State<sqlx::SqlitePool>,
) -> Result<Json<Value>, AppError> {
let email: String = sqlx::query_scalar("SELECT email FROM tutors WHERE id = ?")
.bind(auth.sub)
.fetch_one(&pool)
.await?;
Ok(Json(json!({
"id": auth.sub,
"email": email,
"is_superadmin": auth.is_superadmin
})))
}
async fn logout(jar: CookieJar) -> CookieJar {
jar.remove(Cookie::from("token"))
.remove(Cookie::from("refresh_token"))
}
pub fn router(test_mode: bool) -> Router<AppState> {
@@ -81,6 +159,7 @@ pub fn router(test_mode: bool) -> Router<AppState> {
Router::new()
.route("/api/auth/login", login_route)
.route("/api/auth/refresh", post(refresh))
.route("/api/auth/me", get(me))
.route("/api/auth/logout", post(logout))
}
@@ -88,11 +167,10 @@ pub fn router(test_mode: bool) -> Router<AppState> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::post_json;
use serde_json::json;
#[sqlx::test(migrations = "./migrations")]
async fn login_returns_superadmin_and_cookie(pool: sqlx::SqlitePool) {
async fn login_returns_superadmin_and_cookies(pool: sqlx::SqlitePool) {
let hash = bcrypt::hash("secret", 4).unwrap();
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
.bind("Test")
@@ -120,37 +198,22 @@ mod tests {
let res = serde_json::from_slice::<Value>(&body).unwrap();
assert_eq!(res["is_superadmin"], false);
// Check Set-Cookie header
let cookie = headers.get("set-cookie").unwrap().to_str().unwrap();
assert!(cookie.contains("token="));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("SameSite=Strict"));
}
// Check Set-Cookie headers
let cookies: Vec<_> = headers
.get_all("set-cookie")
.iter()
.map(|v| v.to_str().unwrap())
.collect();
assert!(cookies.iter().any(|c| c.contains("token=")));
assert!(cookies.iter().any(|c| c.contains("refresh_token=")));
#[sqlx::test(migrations = "./migrations")]
async fn login_wrong_password(pool: sqlx::SqlitePool) {
let hash = bcrypt::hash("correct", 4).unwrap();
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
.bind("Test")
.bind("t@test.com")
.bind(&hash)
.execute(&pool)
.await
.unwrap();
let state = AppState {
pool: pool.clone(),
jwt_secret: "testsecret".into(),
test_mode: true,
};
let app = crate::routes::build(state, true);
let (status, _) = post_json(
app,
"/api/auth/login",
"",
json!({"email":"t@test.com","password":"wrong"}),
)
.await;
assert_eq!(status, 401);
// Check lazy rehash happened
let new_hash: String =
sqlx::query_scalar("SELECT password_hash FROM tutors WHERE email = ?")
.bind("t@test.com")
.fetch_one(&pool)
.await
.unwrap();
assert!(new_hash.starts_with("$argon2id$"));
}
}

View File

@@ -17,9 +17,9 @@ use sqlx::SqlitePool;
fn generate_code() -> String {
const CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let mut rng = rand::rng();
let mut rng = rand::thread_rng();
(0..8)
.map(|_| CHARS[rng.random_range(0..CHARS.len())] as char)
.map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char)
.collect()
}

View File

@@ -38,7 +38,16 @@ async fn create_tutor(
return Err(AppError::Unauthorized);
}
let hash = bcrypt::hash(&req.password, 12).map_err(|_| AppError::Unauthorized)?;
let salt = argon2::password_hash::SaltString::generate(&mut rand::thread_rng());
let argon2 = argon2::Argon2::default();
use argon2::password_hash::PasswordHasher;
let hash = argon2
.hash_password(req.password.as_bytes(), &salt)
.map_err(|e| {
tracing::error!(error = %e, "argon2 hash failed");
AppError::Unauthorized
})?
.to_string();
let id = sqlx::query(
"INSERT INTO tutors (name, email, password_hash, is_superadmin) VALUES (?, ?, ?, ?)",

View File

@@ -40,7 +40,7 @@ pub async fn make_token(
.fetch_one(pool)
.await
.unwrap();
crate::auth::encode_jwt(row.0, email, row.1, secret).unwrap()
crate::auth::encode_jwt(row.0, row.1, secret, false).unwrap()
}
/// Build the full Axum app wired with the given pool, plus a Bearer auth header value.

View File

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

View File

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

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

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

View File

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

945
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,7 @@
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';
@@ -57,7 +58,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 +88,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 +107,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 +144,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

@@ -23,7 +23,7 @@ export const auth = {
_isSuperadmin = false;
_authenticated = false;
}
} catch (e) {
} catch (_e) {
_isSuperadmin = false;
_authenticated = false;
} finally {
@@ -40,7 +40,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

@@ -20,8 +20,10 @@
}
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(() => {
@@ -38,9 +40,7 @@
courseName={course?.name ?? ''}
semester={course?.semester ?? ''}
>
{#snippet children()}
{@render children()}
{/snippet}
{@render children()}
</TutorShell>
{:else}
<div style="padding: 2rem; text-align: center;">Redirecting to login...</div>

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

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