Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ca42d10e6 | |||
| 32e7dc5ac1 | |||
| 6ca852117d | |||
| dec92509ff | |||
| 31f8ef74fe | |||
| 536638b594 | |||
| 6cb5968b7b |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
4
Makefile
4
Makefile
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -3,8 +3,7 @@ httpRoute:
|
||||
- tutor.puchstein.dev
|
||||
|
||||
image:
|
||||
tag: v0.1.8
|
||||
tag: v0.1.12
|
||||
|
||||
env:
|
||||
extra:
|
||||
DEMO: "true"
|
||||
extra: {}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
84
frontend/pnpm-lock.yaml
generated
84
frontend/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user