use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::routing::{get, post, put}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; use crate::auth::{ generate_opaque_token, hash_opaque_token, mint_admin_token, verify_password, AdminIdentity, }; use crate::clock::now_ms; use crate::error::{AppError, AppResult}; use crate::layout; use crate::models::{derive_status, KioskRow, KioskView, KIOSK_COLUMNS}; use crate::pins; use crate::state::AppState; pub fn router() -> Router { Router::new() .route("/login", post(login)) .route("/kiosks", get(list_kiosks).post(create_kiosk)) .route( "/kiosks/{kiosk_id}", get(kiosk_detail).patch(update_kiosk).delete(delete_kiosk), ) .route("/kiosks/{kiosk_id}/layout", put(update_layout)) .route("/kiosks/{kiosk_id}/enrollment", post(rotate_enrollment)) } #[derive(Deserialize)] struct LoginRequest { email: String, password: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct LoginResponse { access_token: String, email: String, expires_at: i64, } async fn login( State(state): State, Json(body): Json, ) -> AppResult> { let email = body.email.trim().to_lowercase(); let row: Option<(String, String)> = sqlx::query_as("SELECT id, password_hash FROM admins WHERE email = ?") .bind(&email) .fetch_optional(&state.db) .await?; let (admin_id, password_hash) = row.ok_or_else(|| AppError::Unauthorized("invalid email or password".into()))?; if !verify_password(&body.password, &password_hash) { return Err(AppError::Unauthorized("invalid email or password".into())); } let (access_token, expires_at) = mint_admin_token( &state.config.session_secret, &admin_id, &email, state.config.session_ttl_hours, )?; Ok(Json(LoginResponse { access_token, email, expires_at: expires_at * 1000, })) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct KioskListResponse { kiosks: Vec, } async fn list_kiosks( State(state): State, _admin: AdminIdentity, ) -> AppResult> { let rows: Vec = sqlx::query_as(&format!( "SELECT {KIOSK_COLUMNS} FROM kiosks ORDER BY created_at" )) .fetch_all(&state.db) .await?; let now = now_ms(); let kiosks = rows .into_iter() .map(|row| { let mut view = KioskView::from(row); view.status = derive_status(view.last_seen_at, now).to_string(); view }) .collect(); Ok(Json(KioskListResponse { kiosks })) } #[derive(Deserialize)] struct CreateKioskRequest { name: String, #[serde(default)] location: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct CreateKioskResponse { kiosk: KioskView, enrollment_token: String, } async fn create_kiosk( State(state): State, _admin: AdminIdentity, Json(body): Json, ) -> AppResult> { let name = body.name.trim(); if name.is_empty() { return Err(AppError::BadRequest("kiosk name is required".into())); } let kiosk_id = Uuid::new_v4().to_string(); let room_name = format!("kiosk-{}", &kiosk_id[..8]); let enrollment_token = generate_opaque_token(); let created_at = now_ms(); sqlx::query( "INSERT INTO kiosks (id, name, location, room_name, enrollment_token_hash, status, created_at) VALUES (?, ?, ?, ?, ?, 'offline', ?)", ) .bind(&kiosk_id) .bind(name) .bind(body.location.trim()) .bind(&room_name) .bind(hash_opaque_token(&enrollment_token)) .bind(created_at) .execute(&state.db) .await?; layout::save_layout(&state.db, &kiosk_id, &layout::default_layout(&kiosk_id)).await?; pins::ensure_pin( &state.db, &kiosk_id, state.config.pin_rotation_seconds, state.config.pin_grace_seconds, ) .await?; Ok(Json(CreateKioskResponse { kiosk: KioskView { kiosk_id, name: name.to_string(), location: body.location.trim().to_string(), room_name, status: "offline".to_string(), last_seen_at: None, created_at, metrics: None, metrics_at: None, }, enrollment_token, })) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct KioskDetailResponse { kiosk: KioskView, layout: Value, current_pin: Option, } async fn kiosk_detail( State(state): State, _admin: AdminIdentity, Path(kiosk_id): Path, ) -> AppResult> { let row: Option = sqlx::query_as(&format!("SELECT {KIOSK_COLUMNS} FROM kiosks WHERE id = ?")) .bind(&kiosk_id) .fetch_optional(&state.db) .await?; let row = row.ok_or_else(|| AppError::NotFound("kiosk not found".into()))?; let mut kiosk = KioskView::from(row); kiosk.status = derive_status(kiosk.last_seen_at, now_ms()).to_string(); let layout = layout::load_layout(&state.db, &kiosk_id).await?; let current_pin = pins::current_pin(&state.db, &kiosk_id) .await? .map(|issued| issued.pin); Ok(Json(KioskDetailResponse { kiosk, layout, current_pin, })) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateKioskRequest { name: String, #[serde(default)] location: String, } async fn update_kiosk( State(state): State, _admin: AdminIdentity, Path(kiosk_id): Path, Json(body): Json, ) -> AppResult> { let name = body.name.trim(); if name.is_empty() { return Err(AppError::BadRequest("kiosk name is required".into())); } let result = sqlx::query("UPDATE kiosks SET name = ?, location = ? WHERE id = ?") .bind(name) .bind(body.location.trim()) .bind(&kiosk_id) .execute(&state.db) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("kiosk not found".into())); } let row: KioskRow = sqlx::query_as(&format!("SELECT {KIOSK_COLUMNS} FROM kiosks WHERE id = ?")) .bind(&kiosk_id) .fetch_one(&state.db) .await?; let mut kiosk = KioskView::from(row); kiosk.status = derive_status(kiosk.last_seen_at, now_ms()).to_string(); Ok(Json(kiosk)) } #[derive(Deserialize)] struct UpdateLayoutRequest { layout: Value, } async fn update_layout( State(state): State, _admin: AdminIdentity, Path(kiosk_id): Path, Json(body): Json, ) -> AppResult> { let exists: Option<(String,)> = sqlx::query_as("SELECT id FROM kiosks WHERE id = ?") .bind(&kiosk_id) .fetch_optional(&state.db) .await?; if exists.is_none() { return Err(AppError::NotFound("kiosk not found".into())); } layout::save_layout(&state.db, &kiosk_id, &body.layout).await?; let stored = layout::load_layout(&state.db, &kiosk_id).await?; Ok(Json(stored)) } async fn delete_kiosk( State(state): State, _admin: AdminIdentity, Path(kiosk_id): Path, ) -> AppResult { let result = sqlx::query("DELETE FROM kiosks WHERE id = ?") .bind(&kiosk_id) .execute(&state.db) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("kiosk not found".into())); } Ok(StatusCode::NO_CONTENT) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct EnrollmentResponse { enrollment_token: String, } async fn rotate_enrollment( State(state): State, _admin: AdminIdentity, Path(kiosk_id): Path, ) -> AppResult> { let enrollment_token = generate_opaque_token(); let result = sqlx::query( "UPDATE kiosks SET enrollment_token_hash = ?, kiosk_token_hash = NULL WHERE id = ?", ) .bind(hash_opaque_token(&enrollment_token)) .bind(&kiosk_id) .execute(&state.db) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("kiosk not found".into())); } Ok(Json(EnrollmentResponse { enrollment_token })) }