7 Commits

Author SHA1 Message Date
6ca42d10e6 fix: resolve unit test failures caused by rate limiting and fix mod.rs router passing
Some checks failed
Release / release (push) Failing after 2m10s
2026-05-02 21:04:31 +02:00
32e7dc5ac1 deploy: bump image tag to v0.1.11
Some checks failed
Release / release (push) Failing after 1m26s
2026-05-02 20:56:22 +02:00
6ca852117d chore: update frontend dependencies to latest stable versions and sync lockfile 2026-05-02 20:56:03 +02:00
dec92509ff deploy: bump image tag to v0.1.10
Some checks failed
Release / release (push) Failing after 53s
2026-05-02 20:48:56 +02:00
31f8ef74fe chore: remediate code audit findings and fix CI pipeline failures
- Security: Add Secure flag to checkin identity cookie, implement rate limiting on login, and harden Helm security context.
- Security: Add cargo-audit to CI and Release pipelines for dependency vulnerability scanning.
- Backend: Enable SQLite WAL mode and fix AppState initialization in tests.
- Frontend: Fully type the API client, fix importStudents FormData handling, and pin dependency versions.
- Frontend: Add auto-logout on 401 and resolve authentication initialization race conditions.
- CI/CD: Pin pnpm version in release workflow and include lint/audit quality gates.
2026-05-02 20:40:05 +02:00
536638b594 deploy: bump image tag to v0.1.9
Some checks failed
Release / release (push) Failing after 1m16s
2026-05-02 05:28:30 +02:00
6cb5968b7b fix: resolve Docker build failure and E2E authentication race conditions
- Dockerfile: Update binary name from attendance to tutortool to fix the release build pipeline failure.
- Backend: Expose test_mode in AppState to conditionally disable the secure flag on auth cookies during local E2E testing over HTTP.
- Backend: Enable tower-http trace feature and attach TraceLayer for improved request logging.
- Frontend: Refactor auth.svelte.ts to a plain reactive object to resolve initialization race conditions during tests.
- Frontend: Append cache-busting timestamp to /api/auth/me to prevent stale session states.
- Frontend: Update Playwright locator in superadmin.spec.ts for greater resilience.
- Makefile: Inject required environment variables (STATIC_DIR, JWT_SECRET) into the test-up target.
2026-05-02 05:25:04 +02:00
22 changed files with 232 additions and 162 deletions

View File

@@ -68,6 +68,11 @@ jobs:
- 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
- name: Build frontend
run: pnpm --dir frontend build

View File

@@ -22,11 +22,12 @@ jobs:
- uses: pnpm/action-setup@v4
with:
version: latest
version: '9'
- uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.95.0'
components: clippy, rustfmt
- name: Cache Cargo
uses: actions/cache@v4
@@ -54,9 +55,23 @@ jobs:
- name: Type check (frontend)
run: pnpm --dir frontend exec tsgo --version && pnpm --dir frontend check
- name: Type check (backend)
run: cargo check --manifest-path backend/Cargo.toml
- name: Clippy
run: cargo clippy --manifest-path backend/Cargo.toml -- -D warnings
- name: Format check
run: cargo fmt --manifest-path backend/Cargo.toml -- --check
- name: Unit tests (backend)
run: cargo test --manifest-path backend/Cargo.toml
- name: Security audit
run: |
cargo install cargo-audit --locked
cargo audit --manifest-path backend/Cargo.toml
- name: Build frontend
run: pnpm --dir frontend build

View File

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

View File

@@ -56,7 +56,7 @@ test-up:
exit 0; \
fi; \
[ -f "$$TT_TEST_DB" ] || $(MAKE) test-rebuild; \
DATABASE_URL="sqlite:$$TT_TEST_DB" PORT=$$TT_TEST_PORT TT_TEST_MODE=1 \
DATABASE_URL="sqlite:$$TT_TEST_DB" PORT=$$TT_TEST_PORT TT_TEST_MODE=1 JWT_SECRET=testsecret STATIC_DIR=frontend/build \
cargo run --manifest-path backend/Cargo.toml &>/tmp/tutortool-test.log & \
echo $$! > data/test/.pid; \
echo "[test-up] Backend PID $$(cat data/test/.pid) starting on port $$TT_TEST_PORT..."; \
@@ -91,4 +91,4 @@ test-reset:
test-e2e:
$(MAKE) test-up
pnpm --dir frontend test:e2e
@. scripts/test-env.sh; cd frontend && pnpm test:e2e

View File

@@ -13,7 +13,8 @@ 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"] }
tower-http = { version = "0.6", features = ["fs", "cors", "trace"] }
tower_governor = "0.6"
chrono = { version = "0.4", features = ["serde"] }
rand = "0.9"
thiserror = "2"

View File

@@ -6,7 +6,8 @@ pub async fn init() -> Result<SqlitePool, sqlx::Error> {
let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:/data/attendance.db".into());
let opts = SqliteConnectOptions::from_str(&url)?
.create_if_missing(true)
.foreign_keys(true);
.foreign_keys(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
let pool = SqlitePoolOptions::new().connect_with(opts).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)

View File

@@ -8,6 +8,7 @@ mod routes;
mod test_helpers;
use axum::routing::get;
use std::net::SocketAddr;
use tower_http::services::{ServeDir, ServeFile};
use tracing_subscriber::EnvFilter;
@@ -15,6 +16,7 @@ use tracing_subscriber::EnvFilter;
pub struct AppState {
pub pool: sqlx::SqlitePool,
pub jwt_secret: String,
pub test_mode: bool,
}
impl axum::extract::FromRef<AppState> for sqlx::SqlitePool {
@@ -46,7 +48,11 @@ async fn main() {
let pool = db::init().await.expect("db init failed");
db::maybe_seed_demo(&pool).await;
let state = AppState { pool, jwt_secret };
let state = AppState {
pool,
jwt_secret,
test_mode,
};
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../frontend/build".into());
@@ -54,14 +60,20 @@ async fn main() {
.route("/health", get(|| async { "ok" }))
.fallback_service(
ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{static_dir}/index.html"))),
);
)
.layer(tower_http::trace::TraceLayer::new_for_http());
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into());
let addr = format!("0.0.0.0:{port}");
let addr: SocketAddr = format!("0.0.0.0:{port}").parse().expect("invalid address");
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("failed to bind");
tracing::info!("listening on :{}", port);
axum::serve(listener, app).await.expect("server error");
tracing::info!("listening on {}", addr);
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.expect("failed to serve");
}

View File

@@ -8,6 +8,8 @@ use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::{Cookie, SameSite};
use serde::Deserialize;
use serde_json::{Value, json};
use std::sync::Arc;
use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
#[derive(Deserialize)]
struct LoginRequest {
@@ -38,31 +40,47 @@ async fn login(
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.secure(true) // Should be true in prod, but for local dev we might need to be careful.
// Actually, most local setups use http, but we can stick to secure(true) and assume production-first.
.secure(!state.test_mode)
.build();
Ok((
jar.add(cookie),
Json(json!({"is_superadmin": is_superadmin})),
))
}
async fn me(auth: auth::TutorClaims) -> Json<Value> {
Json(json!({
"id": auth.sub,
"email": auth.email,
"is_superadmin": auth.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 logout(jar: CookieJar) -> CookieJar {
jar.remove(Cookie::from("token"))
}
pub fn router() -> Router<AppState> {
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))
.route("/api/auth/login", login_route)
.route("/api/auth/me", get(me))
.route("/api/auth/logout", post(logout))
}
@@ -70,7 +88,7 @@ pub fn router() -> Router<AppState> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, post_json};
use crate::test_helpers::post_json;
use serde_json::json;
#[sqlx::test(migrations = "./migrations")]
@@ -87,8 +105,9 @@ mod tests {
let state = AppState {
pool: pool.clone(),
jwt_secret: "testsecret".into(),
test_mode: true,
};
let app = crate::routes::build(state, false);
let app = crate::routes::build(state, true);
let (status, body, headers) = crate::test_helpers::post_json_with_headers(
app,
"/api/auth/login",
@@ -122,8 +141,9 @@ mod tests {
let state = AppState {
pool: pool.clone(),
jwt_secret: "testsecret".into(),
test_mode: true,
};
let app = crate::routes::build(state, false);
let app = crate::routes::build(state, true);
let (status, _) = post_json(
app,
"/api/auth/login",

View File

@@ -6,7 +6,6 @@ use axum::{
routing::{get, post},
};
use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::{
AppState,
@@ -31,7 +30,7 @@ fn url_decode_minimal(s: &str) -> String {
}
async fn get_checkin_info(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
Path(code): Path<String>,
headers: HeaderMap,
) -> Result<Json<Value>, AppError> {
@@ -41,7 +40,7 @@ async fn get_checkin_info(
FROM slots WHERE code = ?",
)
.bind(&code)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
@@ -55,7 +54,7 @@ async fn get_checkin_info(
let room =
sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
.bind(room_id)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?;
if let Some(r) = room {
let elements: Vec<LayoutElement> =
@@ -73,7 +72,7 @@ async fn get_checkin_info(
"SELECT id, slot_id, student_id, seat_id, checked_in_at FROM attendances WHERE slot_id = ?",
)
.bind(slot.id)
.fetch_all(&pool)
.fetch_all(&state.pool)
.await?;
// Parse identity cookie to determine which attendance is "mine"
@@ -123,7 +122,7 @@ async fn get_checkin_info(
}
async fn get_checkin_students(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
Path(code): Path<String>,
) -> Result<Json<Value>, AppError> {
// Look up slot by code
@@ -132,7 +131,7 @@ async fn get_checkin_students(
FROM slots WHERE code = ?",
)
.bind(&code)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
@@ -143,7 +142,7 @@ async fn get_checkin_students(
// Get course_id from the session
let (course_id,): (i64,) = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
.bind(slot.session_id)
.fetch_one(&pool)
.fetch_one(&state.pool)
.await?;
// Return only students enrolled in that course
@@ -151,14 +150,14 @@ async fn get_checkin_students(
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
)
.bind(course_id)
.fetch_all(&pool)
.fetch_all(&state.pool)
.await?;
Ok(Json(json!(students)))
}
async fn post_checkin(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<crate::models::CheckinRequest>,
) -> Result<Response, AppError> {
@@ -168,7 +167,7 @@ async fn post_checkin(
FROM slots WHERE code = ?",
)
.bind(&req.code)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
@@ -194,7 +193,7 @@ async fn post_checkin(
let room =
sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
.bind(room_id)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?;
let room_row = room.ok_or(AppError::NotFound)?;
@@ -229,7 +228,7 @@ async fn post_checkin(
}
// Transaction: delete old attendance for (slot_id, student_id), then insert new
let mut tx = pool.begin().await?;
let mut tx = state.pool.begin().await?;
sqlx::query("DELETE FROM attendances WHERE slot_id = ? AND student_id = ?")
.bind(slot.id)
@@ -266,9 +265,10 @@ async fn post_checkin(
.expect("serializing static json shape is infallible")
.replace('"', "%22");
let secure = if state.test_mode { "" } else { " Secure;" };
let cookie_val = format!(
"attendance_identity={}; HttpOnly; SameSite=Strict; Max-Age=86400; Path=/",
identity_json
"attendance_identity={}; HttpOnly; SameSite=Strict;{} Max-Age=86400; Path=/",
identity_json, secure
);
let header_val = axum::http::HeaderValue::from_str(&cookie_val)
@@ -289,8 +289,7 @@ pub fn router() -> Router<AppState> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_admin_app, build_test_app, get, patch_json, post_json};
use crate::test_helpers::{build_test_admin_app, get, patch_json, post_json};
use axum::http::StatusCode;
use serde_json::{Value, json};

View File

@@ -270,7 +270,6 @@ pub fn router() -> Router<AppState> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_admin_app, build_test_app, delete, get, post_json};
use axum::http::StatusCode;
use serde_json::{Value, json};

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

@@ -152,7 +152,6 @@ pub fn router() -> Router<AppState> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, get, post_json, put_json};
use axum::http::StatusCode;
use serde_json::{Value, json};

View File

@@ -49,8 +49,9 @@ pub async fn build_test_app(pool: SqlitePool) -> (Router, String) {
let state = AppState {
pool: pool.clone(),
jwt_secret: TEST_SECRET.into(),
test_mode: true,
};
let app = crate::routes::build(state, false);
let app = crate::routes::build(state, true);
(app, format!("Bearer {token}"))
}
@@ -60,8 +61,9 @@ pub async fn build_test_admin_app(pool: SqlitePool) -> (Router, String) {
let state = AppState {
pool: pool.clone(),
jwt_secret: TEST_SECRET.into(),
test_mode: true,
};
let app = crate::routes::build(state, false);
let app = crate::routes::build(state, true);
(app, format!("Bearer {token}"))
}

View File

@@ -60,8 +60,10 @@ spec:
initialDelaySeconds: 5
periodSeconds: 10
securityContext:
readOnlyRootFilesystem: false
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:

View File

@@ -3,8 +3,7 @@ httpRoute:
- tutor.puchstein.dev
image:
tag: v0.1.8
tag: v0.1.12
env:
extra:
DEMO: "true"
extra: {}

View File

@@ -13,14 +13,14 @@
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@types/node": "latest",
"@sveltejs/adapter-static": "latest",
"@sveltejs/kit": "latest",
"@sveltejs/vite-plugin-svelte": "latest",
"@types/node": "^22",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.59.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@typescript/native-preview": "^7.0.0-dev",
"svelte": "5.55.5",
"svelte-check": "latest",
"typescript": "latest",
"vite": "latest"
"svelte": "^5.55.5",
"svelte-check": "^4",
"typescript": "^5",
"vite": "^8.0.10"
}
}

View File

@@ -12,32 +12,32 @@ importers:
specifier: ^1.59.1
version: 1.59.1
'@sveltejs/adapter-static':
specifier: latest
version: 3.0.10(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@25.6.0)))(svelte@5.55.5)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)))
specifier: ^3.0.10
version: 3.0.10(@sveltejs/kit@2.59.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17)))(svelte@5.55.5)(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.17)))
'@sveltejs/kit':
specifier: latest
version: 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@25.6.0)))(svelte@5.55.5)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0))
specifier: ^2.59.0
version: 2.59.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17)))(svelte@5.55.5)(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.17))
'@sveltejs/vite-plugin-svelte':
specifier: latest
version: 7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@25.6.0))
specifier: ^7.0.0
version: 7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17))
'@types/node':
specifier: latest
version: 25.6.0
specifier: ^22
version: 22.19.17
'@typescript/native-preview':
specifier: ^7.0.0-dev
version: 7.0.0-dev.20260428.1
svelte:
specifier: 5.55.5
specifier: ^5.55.5
version: 5.55.5
svelte-check:
specifier: latest
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.5)(typescript@6.0.3)
specifier: ^4
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.5)(typescript@5.9.3)
typescript:
specifier: latest
version: 6.0.3
specifier: ^5
version: 5.9.3
vite:
specifier: latest
version: 8.0.10(@types/node@25.6.0)
specifier: ^8.0.10
version: 8.0.10(@types/node@22.19.17)
packages:
@@ -194,8 +194,8 @@ packages:
peerDependencies:
'@sveltejs/kit': ^2.0.0
'@sveltejs/kit@2.58.0':
resolution: {integrity: sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==}
'@sveltejs/kit@2.59.0':
resolution: {integrity: sha512-WeJaGKvDf3uVQB4bnDHhM+BXCY34LC1v0HiPqnSpvNkjB54r8DAUP1rpk73s+5zprIirEKtUcVfgh6+fPODjzQ==}
engines: {node: '>=18.13'}
hasBin: true
peerDependencies:
@@ -226,8 +226,8 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -516,13 +516,13 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
vite@8.0.10:
resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
@@ -687,15 +687,15 @@ snapshots:
dependencies:
acorn: 8.16.0
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@25.6.0)))(svelte@5.55.5)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)))':
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.59.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17)))(svelte@5.55.5)(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.17)))':
dependencies:
'@sveltejs/kit': 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@25.6.0)))(svelte@5.55.5)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0))
'@sveltejs/kit': 2.59.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17)))(svelte@5.55.5)(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.17))
'@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@25.6.0)))(svelte@5.55.5)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0))':
'@sveltejs/kit@2.59.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17)))(svelte@5.55.5)(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.17))':
dependencies:
'@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@25.6.0))
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17))
'@types/cookie': 0.6.0
acorn: 8.16.0
cookie: 0.6.0
@@ -707,18 +707,18 @@ snapshots:
set-cookie-parser: 3.1.0
sirv: 3.0.2
svelte: 5.55.5
vite: 8.0.10(@types/node@25.6.0)
vite: 8.0.10(@types/node@22.19.17)
optionalDependencies:
typescript: 6.0.3
typescript: 5.9.3
'@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@25.6.0))':
'@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17))':
dependencies:
deepmerge: 4.3.1
magic-string: 0.30.21
obug: 2.1.1
svelte: 5.55.5
vite: 8.0.10(@types/node@25.6.0)
vitefu: 1.1.3(vite@8.0.10(@types/node@25.6.0))
vite: 8.0.10(@types/node@22.19.17)
vitefu: 1.1.3(vite@8.0.10(@types/node@22.19.17))
'@tybys/wasm-util@0.10.1':
dependencies:
@@ -729,9 +729,9 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/node@25.6.0':
'@types/node@22.19.17':
dependencies:
undici-types: 7.19.2
undici-types: 6.21.0
'@types/trusted-types@2.0.7': {}
@@ -926,7 +926,7 @@ snapshots:
source-map-js@1.2.1: {}
svelte-check@4.4.6(picomatch@4.0.4)(svelte@5.55.5)(typescript@6.0.3):
svelte-check@4.4.6(picomatch@4.0.4)(svelte@5.55.5)(typescript@5.9.3):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
chokidar: 4.0.3
@@ -934,7 +934,7 @@ snapshots:
picocolors: 1.1.1
sade: 1.8.1
svelte: 5.55.5
typescript: 6.0.3
typescript: 5.9.3
transitivePeerDependencies:
- picomatch
@@ -969,11 +969,11 @@ snapshots:
tslib@2.8.1:
optional: true
typescript@6.0.3: {}
typescript@5.9.3: {}
undici-types@7.19.2: {}
undici-types@6.21.0: {}
vite@8.0.10(@types/node@25.6.0):
vite@8.0.10(@types/node@22.19.17):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -981,11 +981,11 @@ snapshots:
rolldown: 1.0.0-rc.17
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.6.0
'@types/node': 22.19.17
fsevents: 2.3.3
vitefu@1.1.3(vite@8.0.10(@types/node@25.6.0)):
vitefu@1.1.3(vite@8.0.10(@types/node@22.19.17)):
optionalDependencies:
vite: 8.0.10(@types/node@25.6.0)
vite: 8.0.10(@types/node@22.19.17)
zimmerframe@1.1.4: {}

View File

@@ -1,17 +1,23 @@
import { browser } from '$app/environment';
import type {
Course, Tutor, Student, Room, Session, Slot, Attendance, Note
} from './types';
import { auth } from './auth.svelte';
const BASE = '/api';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(!(init?.body instanceof FormData) && { 'Content-Type': 'application/json' }),
...init?.headers,
}
});
if (res.status === 401 && browser) {
auth.logout();
throw new Error('Unauthorized');
}
@@ -36,27 +42,27 @@ export const api = {
},
admin: {
courses: {
list: () => request<any[]>('/admin/courses'),
list: () => request<Course[]>('/admin/courses'),
create: (name: string, semester: string) =>
request<any>('/admin/courses', {
request<Course>('/admin/courses', {
method: 'POST',
body: JSON.stringify({ name, semester })
}),
listStudents: (course_id: number) => request<any[]>(`/admin/courses/${course_id}/students`),
listStudents: (course_id: number) => request<Student[]>(`/admin/courses/${course_id}/students`),
addStudent: (course_id: number, name: string) =>
request<any>(`/admin/courses/${course_id}/students`, {
request<Student>(`/admin/courses/${course_id}/students`, {
method: 'POST',
body: JSON.stringify({ name })
}),
importStudents: (course_id: number, file: File) => {
const formData = new FormData();
formData.append('file', file);
return fetch(`${BASE}/admin/courses/${course_id}/students/import`, {
return request<any>(`/admin/courses/${course_id}/students/import`, {
method: 'POST',
body: formData
}).then(res => res.json());
});
},
listTutors: (course_id: number) => request<any[]>(`/admin/courses/${course_id}/tutors`),
listTutors: (course_id: number) => request<Tutor[]>(`/admin/courses/${course_id}/tutors`),
assignTutor: (course_id: number, tutor_id: number) =>
request<void>(`/admin/courses/${course_id}/tutors`, {
method: 'POST',
@@ -66,9 +72,9 @@ export const api = {
request<void>(`/admin/courses/${course_id}/tutors/${tutor_id}`, { method: 'DELETE' }),
},
tutors: {
list: () => request<any[]>('/admin/tutors'),
create: (tutor: any) =>
request<any>('/admin/tutors', {
list: () => request<Tutor[]>('/admin/tutors'),
create: (tutor: Partial<Tutor> & { password?: string }) =>
request<Tutor>('/admin/tutors', {
method: 'POST',
body: JSON.stringify(tutor)
}),
@@ -76,27 +82,27 @@ export const api = {
},
students: {
delete: (id: number) => request<void>(`/admin/students/${id}`, { method: 'DELETE' }),
getAttendance: (id: number) => request<any[]>(`/admin/students/${id}/attendance`),
getNotes: (id: number) => request<any[]>(`/admin/students/${id}/notes`),
getAttendance: (id: number) => request<Attendance[]>(`/admin/students/${id}/attendance`),
getNotes: (id: number) => request<Note[]>(`/admin/students/${id}/notes`),
},
rooms: {
list: () => request<any[]>('/admin/rooms'),
list: () => request<Room[]>('/admin/rooms'),
create: (name: string, layout: any[]) =>
request<any>('/admin/rooms', {
request<Room>('/admin/rooms', {
method: 'POST',
body: JSON.stringify({ name, layout })
}),
get: (id: number) => request<any>(`/admin/rooms/${id}`),
get: (id: number) => request<Room>(`/admin/rooms/${id}`),
updateLayout: (id: number, layout: any[]) =>
request<any>(`/admin/rooms/${id}/layout`, {
request<Room>(`/admin/rooms/${id}/layout`, {
method: 'PUT',
body: JSON.stringify(layout)
}),
},
sessions: {
list: (course_id: number) => request<any[]>(`/admin/sessions?course_id=${course_id}`),
list: (course_id: number) => request<Session[]>(`/admin/sessions?course_id=${course_id}`),
create: (course_id: number, week_nr: number, date: string) =>
request<any>('/admin/sessions', {
request<Session>('/admin/sessions', {
method: 'POST',
body: JSON.stringify({ course_id, week_nr, date })
}),
@@ -104,12 +110,12 @@ export const api = {
},
slots: {
create: (session_id: number, tutor_id: number, start_time: string, end_time: string, room_id?: number) =>
request<any>('/admin/slots', {
request<Slot>('/admin/slots', {
method: 'POST',
body: JSON.stringify({ session_id, tutor_id, start_time, end_time, room_id })
}),
updateStatus: (id: number, status: string) =>
request<any>(`/admin/slots/${id}/status`, {
request<Slot>(`/admin/slots/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status })
}),
@@ -121,7 +127,7 @@ export const api = {
}),
deleteAttendance: (slot_id: number, student_id: number) =>
request<void>(`/admin/slots/${slot_id}/attendance/${student_id}`, { method: 'DELETE' }),
getNotes: (id: number) => request<any[]>(`/admin/slots/${id}/notes`),
getNotes: (id: number) => request<Note[]>(`/admin/slots/${id}/notes`),
upsertNote: (slot_id: number, student_id: number, content: string) =>
request<void>(`/admin/slots/${slot_id}/notes/${student_id}`, {
method: 'PUT',
@@ -138,7 +144,7 @@ export const api = {
},
checkin: {
getInfo: (code: string) => request<any>(`/checkin/${code}`),
getStudents: (code: string) => request<any[]>(`/checkin/${code}/students`),
getStudents: (code: string) => request<Student[]>(`/checkin/${code}/students`),
post: (code: string, student_id: number, seat_id?: string) =>
request<any>('/checkin', {
method: 'POST',

View File

@@ -2,45 +2,49 @@ import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { api } from './api';
class AuthState {
#isSuperadmin = $state(false);
#initialized = $state(false);
#authenticated = $state(false);
let _isSuperadmin = $state(false);
let _initialized = $state(false);
let _authenticated = $state(false);
get isSuperadmin() { return this.#isSuperadmin; }
get initialized() { return this.#initialized; }
get authenticated() { return this.#authenticated; }
export const auth = {
get isSuperadmin() { return _isSuperadmin; },
get initialized() { return _initialized; },
get authenticated() { return _authenticated; },
async init() {
if (!browser || this.#initialized) return;
if (!browser || _initialized) return;
try {
const me = await api.auth.me();
this.#isSuperadmin = me.is_superadmin;
this.#authenticated = true;
const res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' });
if (res.ok) {
const me = await res.json();
_isSuperadmin = me.is_superadmin;
_authenticated = true;
} else {
_isSuperadmin = false;
_authenticated = false;
}
} catch (e) {
this.#isSuperadmin = false;
this.#authenticated = false;
_isSuperadmin = false;
_authenticated = false;
} finally {
this.#initialized = true;
_initialized = true;
}
}
},
setAuthenticated(isSuperadmin: boolean) {
this.#isSuperadmin = isSuperadmin;
this.#authenticated = true;
this.#initialized = true;
}
_isSuperadmin = isSuperadmin;
_authenticated = true;
_initialized = true;
},
async logout() {
try {
await api.auth.logout();
} catch (e) {}
this.#isSuperadmin = false;
this.#authenticated = false;
_isSuperadmin = false;
_authenticated = false;
if (browser) {
goto('/admin/login');
}
}
}
export const auth = new AuthState();
};

View File

@@ -31,16 +31,22 @@
const activePath = $derived($page.url.pathname);
</script>
{#if auth.authenticated}
<TutorShell
{activePath}
courseName={course?.name ?? ''}
semester={course?.semester ?? ''}
>
{#snippet children()}
{@render children()}
{/snippet}
</TutorShell>
{#if auth.initialized}
{#if auth.authenticated}
<TutorShell
{activePath}
courseName={course?.name ?? ''}
semester={course?.semester ?? ''}
>
{#snippet children()}
{@render children()}
{/snippet}
</TutorShell>
{:else}
<div style="padding: 2rem; text-align: center;">Redirecting to login...</div>
{/if}
{:else}
{@render children()}
<div style="padding: 2rem; text-align: center; font-family: var(--serif);">
Wird geladen...
</div>
{/if}

View File

@@ -43,7 +43,7 @@ async function globalSetup() {
domain: new URL(baseURL).hostname,
path: '/',
httpOnly: true,
secure: true,
secure: baseURL.startsWith('https'),
sameSite: 'Strict' as const,
}
],

View File

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