//! Admin (authenticated) surface: login/logout/session, user listing, and publishing. use auth::{AuthUser, Authorized, ManageUsers, PublishObjects}; use axum::{ Json, Router, extract::{Path, State}, http::StatusCode, routing::{get, post}, }; use domain::{AuditActor, ObjectId, Visibility}; use serde::{Deserialize, Serialize}; use tower_sessions::Session; use utoipa::ToSchema; use crate::{AppState, reindex}; /// Credentials for password login. #[derive(Deserialize, ToSchema)] pub(crate) struct LoginRequest { pub email: String, pub password: String, } /// A user as exposed on the admin surface (no password material). #[derive(Serialize, ToSchema)] pub(crate) struct UserView { pub id: String, pub email: String, pub role: String, } /// Desired visibility for a publish/unpublish request. #[derive(Deserialize, ToSchema)] pub(crate) struct VisibilityRequest { pub visibility: Visibility, } /// Log in with email + password. On success establishes a session (Set-Cookie) and /// returns 204; on failure 401 with no detail (no user enumeration). #[utoipa::path( post, path = "/api/admin/login", request_body = LoginRequest, responses((status = 204, description = "Logged in"), (status = 401, description = "Invalid credentials")) )] pub(crate) async fn login( State(state): State, session: Session, Json(req): Json, ) -> Result { let normalized = req.email.trim().to_lowercase(); let credentials = db::users::credentials_by_email(state.db.pool(), &normalized) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let verified = match &credentials { Some((_, hash)) => auth::verify_password(&req.password, hash), None => { auth::verify_dummy(&req.password); false } }; if !verified { return Err(StatusCode::UNAUTHORIZED); } let (user, _) = credentials.expect("verified implies Some"); auth::establish_session(&session, user.id, &user.email, user.role) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::NO_CONTENT) } /// Log out: clear the session. #[utoipa::path(post, path = "/api/admin/logout", responses((status = 204, description = "Logged out")))] pub(crate) async fn logout(session: Session) -> Result { session .flush() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::NO_CONTENT) } /// The current authenticated user. #[utoipa::path(get, path = "/api/admin/me", responses((status = 200, body = UserView), (status = 401)))] pub(crate) async fn me(user: AuthUser) -> Json { Json(UserView { id: user.id.to_string(), email: user.email.as_str().to_owned(), role: user.role.as_str().to_owned(), }) } /// List all users (Admin only). #[utoipa::path(get, path = "/api/admin/users", responses((status = 200, body = [UserView]), (status = 401), (status = 403)))] pub(crate) async fn list_users( _auth: Authorized, State(state): State, ) -> Result>, StatusCode> { let users = db::users::list_users(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json( users .into_iter() .map(|u| UserView { id: u.id.to_string(), email: u.email.as_str().to_owned(), role: u.role.as_str().to_owned(), }) .collect(), )) } /// Change an object's visibility (publish/unpublish). Requires `PublishObjects`. #[utoipa::path( post, path = "/api/admin/objects/{id}/visibility", params(("id" = String, Path, description = "Object id (UUID)")), request_body = VisibilityRequest, responses( (status = 204, description = "Visibility changed"), (status = 401), (status = 403), (status = 404, description = "No such object"), (status = 409, description = "Illegal visibility transition") ) )] pub(crate) async fn set_visibility( _auth: Authorized, State(state): State, Path(id): Path, Json(req): Json, ) -> Result { // 404 (not 400) for an unparseable id — same non-leaking convention as the public // surface: never reveal whether an id could exist. let object_id = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; let mut tx = state .db .pool() .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // TODO(#7): record the per-user actor (AuthUser carries the id) once auth-event // auditing lands; System for now. let result = db::catalog::set_visibility(&mut tx, AuditActor::System, object_id, req.visibility).await; match result { Ok(()) => { tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; reindex(&state, object_id).await; Ok(StatusCode::NO_CONTENT) } Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND), Err(db::catalog::VisibilityError::Illegal(_)) => Err(StatusCode::CONFLICT), Err(db::catalog::VisibilityError::MissingRequiredFields(_)) => { Err(StatusCode::UNPROCESSABLE_ENTITY) } Err(db::catalog::VisibilityError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } /// Admin routes, parameterized over [`AppState`]. pub(crate) fn routes() -> Router { Router::new() .route("/api/admin/login", post(login)) .route("/api/admin/logout", post(logout)) .route("/api/admin/me", get(me)) .route("/api/admin/users", get(list_users)) .route("/api/admin/objects/{id}/visibility", post(set_visibility)) }