#![allow(clippy::missing_errors_doc)] #![allow(clippy::unused_async)] //! Admin area: cookie-based JWT login and the booking editor. //! //! There is exactly one admin. Login is gated to the `ADMIN_EMAIL` env value //! so any other user row in the DB cannot reach the admin pages. use axum::extract::FromRequestParts; use axum::http::request::Parts; use axum::response::Redirect; use axum_extra::extract::cookie::{Cookie, CookieJar}; use loco_rs::auth::jwt; use loco_rs::prelude::*; use sea_orm::QueryOrder; use serde::Deserialize; use crate::controllers::calendar::{self, build_calendar, current_lang, FIRST_HOUR, LAST_HOUR}; use crate::models::_entities::{bookings, courts}; use crate::models::users; const AUTH_COOKIE: &str = "auth_token"; fn admin_email() -> String { std::env::var("ADMIN_EMAIL").unwrap_or_default() } fn jwt_settings(ctx: &AppContext) -> Option<(String, u64)> { let jwt = ctx.config.auth.as_ref()?.jwt.as_ref()?; Some((jwt.secret.clone(), jwt.expiration)) } /// Request guard for admin-only routes. On any failure it redirects to the /// login page instead of returning an error. pub struct AdminAuth { #[allow(dead_code)] pub user: users::Model, } /// Returns the logged-in admin user if the request carries a valid admin /// cookie. Unlike the [`AdminAuth`] guard this never rejects, so public pages /// can detect an admin visitor without redirecting non-admins away. pub async fn current_admin(ctx: &AppContext, jar: &CookieJar) -> Option { let admin = admin_email(); if admin.is_empty() { return None; } let token = jar.get(AUTH_COOKIE).map(|c| c.value().to_string())?; let (secret, _) = jwt_settings(ctx)?; let claims = jwt::JWT::new(&secret).validate(&token).ok()?; let user = users::Model::find_by_pid(&ctx.db, &claims.claims.pid) .await .ok()?; (user.email == admin).then_some(user) } impl FromRequestParts for AdminAuth { type Rejection = Redirect; async fn from_request_parts( parts: &mut Parts, ctx: &AppContext, ) -> std::result::Result { let jar = CookieJar::from_headers(&parts.headers); match current_admin(ctx, &jar).await { Some(user) => Ok(Self { user }), None => Err(Redirect::to("/admin/login")), } } } // ---------------------------------------------------------------- login ---- fn render_login(v: &TeraView, lang: &str, error: bool) -> Result { format::render().view( v, "admin/login.html", data!({ "lang": lang, "is_admin": false, "error": error }), ) } #[debug_handler] pub async fn login_form(ViewEngine(v): ViewEngine, jar: CookieJar) -> Result { render_login(&v, ¤t_lang(&jar), false) } #[derive(Debug, Deserialize)] pub struct LoginForm { pub email: String, pub password: String, } #[debug_handler] pub async fn login_submit( ViewEngine(v): ViewEngine, State(ctx): State, jar: CookieJar, Form(form): Form, ) -> Result { let lang = current_lang(&jar); let admin = admin_email(); if admin.is_empty() || form.email != admin { return render_login(&v, &lang, true); } let Ok(user) = users::Model::find_by_email(&ctx.db, &form.email).await else { return render_login(&v, &lang, true); }; if !user.verify_password(&form.password) { return render_login(&v, &lang, true); } let (secret, expiration) = jwt_settings(&ctx).ok_or_else(|| Error::string("JWT is not configured"))?; let token = user.generate_jwt(&secret, expiration)?; let jar = jar.add( Cookie::build((AUTH_COOKIE, token)) .path("/") .http_only(true) .build(), ); Ok((jar, Redirect::to("/admin")).into_response()) } #[debug_handler] pub async fn logout(jar: CookieJar) -> Result { let jar = jar.remove(Cookie::build((AUTH_COOKIE, "")).path("/").build()); Ok((jar, Redirect::to("/admin/login")).into_response()) } // ------------------------------------------------------------ dashboard ---- #[debug_handler] pub async fn dashboard( _auth: AdminAuth, ViewEngine(v): ViewEngine, State(ctx): State, jar: CookieJar, Query(q): Query, ) -> Result { let lang = current_lang(&jar); let page = build_calendar(&ctx, &lang, true, true, q.court, q.week).await?; format::render().view(&v, "calendar/week.html", &page) } // ---------------------------------------------------------------- courts --- #[derive(Debug, Deserialize)] pub struct CourtsQuery { /// Set to `name` when a court-delete confirmation name did not match. pub err: Option, } #[debug_handler] pub async fn courts_page( _auth: AdminAuth, ViewEngine(v): ViewEngine, State(ctx): State, jar: CookieJar, Query(q): Query, ) -> Result { let lang = current_lang(&jar); let list = courts::Entity::find() .order_by_asc(courts::Column::Id) .all(&ctx.db) .await?; let items: Vec<_> = list .iter() .map(|c| { data!({ "id": c.id, "name": c.name.clone().unwrap_or_else(|| format!("Court {}", c.id)), "surface": c.surface.clone().unwrap_or_default(), "indoor": c.indoor.unwrap_or(false), }) }) .collect(); format::render().view( &v, "admin/courts.html", data!({ "lang": lang, "is_admin": true, "logged_in": true, "courts": items, "name_error": q.err.as_deref() == Some("name"), }), ) } #[derive(Debug, Deserialize)] pub struct CourtForm { pub name: String, pub surface: Option, pub indoor: Option, } #[debug_handler] pub async fn create_court( _auth: AdminAuth, State(ctx): State, Form(form): Form, ) -> Result { courts::ActiveModel { name: Set(Some(form.name)), surface: Set(form.surface.filter(|s| !s.is_empty())), indoor: Set(Some(form.indoor.is_some())), ..Default::default() } .insert(&ctx.db) .await?; Ok(Redirect::to("/admin/courts").into_response()) } #[derive(Debug, Deserialize)] pub struct DeleteCourtForm { /// The court name the admin retyped to confirm removal. pub confirm_name: String, } /// Removes a court. As a safeguard the admin must retype the court's exact /// name; a mismatch aborts and redirects back with an error. Deleting a court /// also removes all of its bookings, since they would otherwise be orphaned. #[debug_handler] pub async fn delete_court( _auth: AdminAuth, State(ctx): State, Path(id): Path, Form(form): Form, ) -> Result { let court = courts::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or(Error::NotFound)?; let actual = court .name .clone() .unwrap_or_else(|| format!("Court {}", court.id)); if form.confirm_name.trim() != actual { return Ok(Redirect::to("/admin/courts?err=name").into_response()); } bookings::Entity::delete_many() .filter(bookings::Column::CourtId.eq(id)) .exec(&ctx.db) .await?; courts::Entity::delete_by_id(id).exec(&ctx.db).await?; Ok(Redirect::to("/admin/courts").into_response()) } // --------------------------------------------------------------- bookings -- fn hour_options() -> Vec { (FIRST_HOUR..=LAST_HOUR) .map(|h| data!({ "v": h, "label": format!("{h:02}:00") })) .collect() } /// End-of-block hour options (07:00‥22:00) for the "until" select. A booking /// covers the hours in `[start, end)`, so the end runs one slot past the last /// bookable hour. fn end_hour_options() -> Vec { (FIRST_HOUR + 1..=LAST_HOUR + 1) .map(|h| data!({ "v": h, "label": format!("{h:02}:00") })) .collect() } #[derive(Debug, Deserialize)] pub struct NewBookingQuery { pub court: Option, pub date: Option, pub hour: Option, } #[debug_handler] pub async fn booking_new( _auth: AdminAuth, ViewEngine(v): ViewEngine, State(ctx): State, jar: CookieJar, Query(q): Query, ) -> Result { let lang = current_lang(&jar); let court_list = courts::Entity::find() .order_by_asc(courts::Column::Id) .all(&ctx.db) .await?; let court_id = q .court .or_else(|| court_list.first().map(|c| c.id)) .unwrap_or(0); let court_name = court_list .iter() .find(|c| c.id == court_id) .and_then(|c| c.name.clone()) .unwrap_or_else(|| format!("Court {court_id}")); let hour_start = q.hour.unwrap_or(FIRST_HOUR).clamp(FIRST_HOUR, LAST_HOUR); let hour_end = (hour_start + 1).min(LAST_HOUR + 1); format::render().view( &v, "admin/booking_form.html", data!({ "lang": lang, "is_admin": true, "logged_in": true, "mode": "new", "action": "/admin/booking", "court_id": court_id, "court_name": court_name, "date": q.date.unwrap_or_default(), "hour_start": hour_start, "hour_end": hour_end, "repeat_weeks": 1, "color": "#3b82f6", "name": "", "title": "", "contact": "", "note": "", "hours": hour_options(), "hours_end": end_hour_options(), "booking_id": 0, }), ) } /// Form fields for editing a single existing booking. #[derive(Debug, Deserialize)] pub struct BookingForm { pub court_id: i32, pub date: String, pub hour: i32, pub color: String, pub name: String, pub title: Option, pub contact: Option, pub note: Option, } /// Form fields for creating bookings. Unlike editing, creation books a range /// of hours (`[hour_start, hour_end)`) and can repeat the block weekly. #[derive(Debug, Deserialize)] pub struct BookingCreateForm { pub court_id: i32, pub date: String, pub hour_start: i32, pub hour_end: i32, pub repeat_weeks: Option, pub color: String, pub name: String, pub title: Option, pub contact: Option, pub note: Option, } fn parse_date(s: &str) -> Result { chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") .map_err(|_| Error::string("invalid date")) } /// Creates one or more bookings from the admin form. The selected hour range /// expands into one row per hour, and — when `repeat_weeks > 1` — the whole /// block is duplicated on the same weekday for each following week. A slot /// that is already taken is skipped, so an overlap can never double-book. #[debug_handler] pub async fn booking_create( _auth: AdminAuth, State(ctx): State, Form(form): Form, ) -> Result { let start_date = parse_date(&form.date)?; // Normalise the hour block to `[hour_start, hour_end)`; a non-positive // span falls back to a single hour so the form cannot create nothing. let hour_start = form.hour_start.clamp(FIRST_HOUR, LAST_HOUR); let hour_end = form.hour_end.clamp(hour_start + 1, LAST_HOUR + 1); let weeks = form.repeat_weeks.unwrap_or(1).clamp(1, 52); let title = form.title.filter(|s| !s.is_empty()); let contact = form.contact.filter(|s| !s.is_empty()); let note = form.note.filter(|s| !s.is_empty()); let last_date = start_date + chrono::Duration::weeks(i64::from(weeks - 1)); // Slots already booked on this court within the affected window, used to // skip conflicts rather than insert a duplicate. let taken: std::collections::HashSet<(chrono::NaiveDate, i32)> = bookings::Entity::find() .filter(bookings::Column::CourtId.eq(form.court_id)) .filter(bookings::Column::Date.gte(start_date)) .filter(bookings::Column::Date.lte(last_date)) .all(&ctx.db) .await? .into_iter() .map(|b| (b.date, b.hour)) .collect(); for w in 0..weeks { let date = start_date + chrono::Duration::weeks(i64::from(w)); for hour in hour_start..hour_end { if taken.contains(&(date, hour)) { continue; } bookings::ActiveModel { court_id: Set(form.court_id), date: Set(date), hour: Set(hour), color: Set(form.color.clone()), name: Set(form.name.clone()), title: Set(title.clone()), contact: Set(contact.clone()), note: Set(note.clone()), ..Default::default() } .insert(&ctx.db) .await?; } } Ok(Redirect::to(&format!("/admin?court={}&week={}", form.court_id, form.date)).into_response()) } #[debug_handler] pub async fn booking_edit( _auth: AdminAuth, ViewEngine(v): ViewEngine, State(ctx): State, jar: CookieJar, Path(id): Path, ) -> Result { let lang = current_lang(&jar); let booking = bookings::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or(Error::NotFound)?; let court_name = courts::Entity::find_by_id(booking.court_id) .one(&ctx.db) .await? .and_then(|c| c.name) .unwrap_or_else(|| format!("Court {}", booking.court_id)); format::render().view( &v, "admin/booking_form.html", data!({ "lang": lang, "is_admin": true, "logged_in": true, "mode": "edit", "action": format!("/admin/booking/{id}"), "court_id": booking.court_id, "court_name": court_name, "date": booking.date.format("%Y-%m-%d").to_string(), "hour": booking.hour, "color": booking.color, "name": booking.name, "title": booking.title.unwrap_or_default(), "contact": booking.contact.unwrap_or_default(), "note": booking.note.unwrap_or_default(), "hours": hour_options(), "booking_id": id, }), ) } #[debug_handler] pub async fn booking_update( _auth: AdminAuth, State(ctx): State, Path(id): Path, Form(form): Form, ) -> Result { let date = parse_date(&form.date)?; let booking = bookings::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or(Error::NotFound)?; let mut active = booking.into_active_model(); active.court_id = Set(form.court_id); active.date = Set(date); active.hour = Set(form.hour); active.color = Set(form.color); active.name = Set(form.name); active.title = Set(form.title.filter(|s| !s.is_empty())); active.contact = Set(form.contact.filter(|s| !s.is_empty())); active.note = Set(form.note.filter(|s| !s.is_empty())); active.update(&ctx.db).await?; Ok(Redirect::to(&format!("/admin?court={}&week={}", form.court_id, form.date)).into_response()) } #[debug_handler] pub async fn booking_delete( _auth: AdminAuth, State(ctx): State, Path(id): Path, ) -> Result { let court = bookings::Entity::find_by_id(id) .one(&ctx.db) .await? .map_or(0, |b| b.court_id); bookings::Entity::delete_by_id(id).exec(&ctx.db).await?; Ok(Redirect::to(&format!("/admin?court={court}")).into_response()) } pub fn routes() -> Routes { Routes::new() .prefix("admin") .add("/login", get(login_form)) .add("/login", post(login_submit)) .add("/logout", post(logout)) .add("/", get(dashboard)) .add("/courts", get(courts_page)) .add("/courts", post(create_court)) .add("/courts/{id}/delete", post(delete_court)) .add("/booking", get(booking_new)) .add("/booking", post(booking_create)) .add("/booking/{id}", get(booking_edit)) .add("/booking/{id}", post(booking_update)) .add("/booking/{id}/delete", post(booking_delete)) }