use axum::extract::{Path, Query, State};
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::admin;
use crate::admin::{mfa, users};
use crate::webadmin::AdminState;
use crate::webadmin::error::AdminError;
use crate::webadmin::handlers::mfa::verify_current_password;
use crate::webadmin::handlers::operators::{
OperatorAction, apply_operator_action, find, refuse_self_target,
};
use crate::webadmin::handlers::paging::{Page, PageParams};
use crate::webadmin::pages::auth::{PageAdminRead, PageAdminWrite};
use crate::webadmin::pages::error::{PageError, redirect};
use crate::webadmin::pages::{chrome, flash, page_value, pager, respond, respond_fragment};
use crate::webadmin::session::AdminClientIp;
use acme_proxy_store::admin_session::AdminSession;
use acme_proxy_store::admin_user::AdminRole;
use acme_proxy_store::admin_user::AdminUser;
#[derive(Debug, Default, Deserialize)]
pub struct StepUpForm {
#[serde(default)]
pub password: String,
}
pub async fn list_operators(
State(state): State<AdminState>,
Query(params): Query<PageParams>,
session: PageAdminRead,
) -> Result<Html<String>, PageError> {
let page = params.resolve(&state.config);
let (operators, total) = rows(page, &state).await?;
let mut context = chrome(&session, "operators", "Operators");
context.insert("page".to_string(), page_value(operators, total));
context.insert(
"pager".to_string(),
pager(page, total, "/ui/operators", &[], "#operators-table"),
);
respond(
&state,
session.hx,
"operators/list.html",
"operators/_table.html",
context,
)
}
pub async fn get_operator(
State(state): State<AdminState>,
Path(username): Path<String>,
session: PageAdminRead,
) -> Result<Response, PageError> {
let target = find(&username, &state).await?;
if target.id == session.auth.user.id {
return Ok(redirect("/ui/account", session.hx));
}
let mut context = chrome(&session, "operators", "Operator");
for (key, value) in detail_context(&state, &target).await? {
context.insert(key, value);
}
Ok(respond(
&state,
session.hx,
"operators/detail.html",
"operators/_card.html",
context,
)?
.into_response())
}
pub async fn disable_operator(
State(state): State<AdminState>,
Path(username): Path<String>,
AdminClientIp(client): AdminClientIp,
session: PageAdminWrite,
request_context: acme_proxy_core::audit::RequestContext,
axum::Form(body): axum::Form<StepUpForm>,
) -> Result<Response, PageError> {
act(
&state,
&session,
&username,
&body.password,
client,
&request_context,
OperatorAction::SetStatus { active: false },
flash("ok", "Operator disabled. Their sessions were revoked."),
)
.await
}
pub async fn enable_operator(
State(state): State<AdminState>,
Path(username): Path<String>,
AdminClientIp(client): AdminClientIp,
session: PageAdminWrite,
request_context: acme_proxy_core::audit::RequestContext,
axum::Form(body): axum::Form<StepUpForm>,
) -> Result<Response, PageError> {
act(
&state,
&session,
&username,
&body.password,
client,
&request_context,
OperatorAction::SetStatus { active: true },
flash("ok", "Operator enabled."),
)
.await
}
pub async fn reset_operator_totp(
State(state): State<AdminState>,
Path(username): Path<String>,
AdminClientIp(client): AdminClientIp,
session: PageAdminWrite,
request_context: acme_proxy_core::audit::RequestContext,
axum::Form(body): axum::Form<StepUpForm>,
) -> Result<Response, PageError> {
act(
&state,
&session,
&username,
&body.password,
client,
&request_context,
OperatorAction::ResetTotp,
flash(
"warn",
"Their second factor and recovery codes were removed. They can \
sign in with a password alone until they enrol again.",
),
)
.await
}
pub async fn revoke_operator_session(
State(state): State<AdminState>,
Path((username, id)): Path<(String, String)>,
AdminClientIp(client): AdminClientIp,
session: PageAdminWrite,
request_context: acme_proxy_core::audit::RequestContext,
axum::Form(body): axum::Form<StepUpForm>,
) -> Result<Response, PageError> {
act(
&state,
&session,
&username,
&body.password,
client,
&request_context,
OperatorAction::RevokeSession { fingerprint: &id },
flash("ok", "Session revoked."),
)
.await
}
#[derive(Debug, Default, Deserialize)]
pub struct OperatorContactForm {
#[serde(default)]
pub password: String,
#[serde(default)]
pub contact: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct OperatorRoleForm {
#[serde(default)]
pub password: String,
#[serde(default)]
pub role: String,
}
pub async fn set_operator_contact(
State(state): State<AdminState>,
Path(username): Path<String>,
AdminClientIp(client): AdminClientIp,
session: PageAdminWrite,
request_context: acme_proxy_core::audit::RequestContext,
axum::Form(form): axum::Form<OperatorContactForm>,
) -> Result<Response, PageError> {
let banner = if form.contact.trim().is_empty() {
flash(
"warn",
"Their notification address was cleared: security notifications cannot reach them.",
)
} else {
flash(
"ok",
"Their notification address was changed. The previous one was told.",
)
};
act(
&state,
&session,
&username,
&form.password,
client,
&request_context,
OperatorAction::SetContact {
contact: Some(form.contact.as_str()),
},
banner,
)
.await
}
pub async fn set_operator_role(
State(state): State<AdminState>,
Path(username): Path<String>,
AdminClientIp(client): AdminClientIp,
session: PageAdminWrite,
request_context: acme_proxy_core::audit::RequestContext,
axum::Form(form): axum::Form<OperatorRoleForm>,
) -> Result<Response, PageError> {
let role = match form.role.parse::<AdminRole>() {
Ok(role) => role,
Err(message) => {
let target = find(&username, &state).await?;
refuse_self_target(&session.auth.user, &target)?;
let context = detail_context(&state, &target).await?;
return super::refuse_with_card(
&state,
"operators/_card.html",
context,
&AdminError::bad_request(message),
);
}
};
act(
&state,
&session,
&username,
&form.password,
client,
&request_context,
OperatorAction::SetRole { role },
flash(
"ok",
format!(
"Role changed to {}. Their sessions were revoked.",
role.as_str()
),
),
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn act(
state: &AdminState,
session: &PageAdminWrite,
username: &str,
password: &str,
client: Option<std::net::IpAddr>,
request_context: &acme_proxy_core::audit::RequestContext,
action: OperatorAction<'_>,
banner: Value,
) -> Result<Response, PageError> {
let mut target = find(username, state).await?;
refuse_self_target(&session.auth.user, &target)?;
if let Some(refusal) =
refuse_without_password(state, session, &target, password, client).await?
{
return Ok(refusal);
}
if let Err(error) = apply_operator_action(
state,
&session.auth.user,
&mut target,
action,
client,
request_context,
"ui",
)
.await
{
if error.status.is_client_error() && error.status != axum::http::StatusCode::NOT_FOUND {
let context = detail_context(state, &target).await?;
return super::refuse_with_card(state, "operators/_card.html", context, &error);
}
return Err(error.into());
}
respond_card(state, &target, banner).await
}
async fn refuse_without_password(
state: &AdminState,
session: &PageAdminWrite,
target: &AdminUser,
password: &str,
client: Option<std::net::IpAddr>,
) -> Result<Option<Response>, PageError> {
let Err(error) =
verify_current_password(&session.auth.user, password, client, &state.logins).await
else {
return Ok(None);
};
let context = detail_context(state, target).await?;
Ok(Some(super::refuse_with_card(
state,
"operators/_card.html",
context,
&error,
)?))
}
async fn respond_card(
state: &AdminState,
target: &AdminUser,
banner: Value,
) -> Result<Response, PageError> {
let mut context = detail_context(state, target).await?;
context.insert("flash".to_string(), banner);
Ok(respond_fragment(state, "operators/_card.html", context)?.into_response())
}
async fn rows(page: Page, state: &AdminState) -> Result<(Vec<Value>, i64), PageError> {
let (operators, total) =
users::list_users(page.limit, page.offset, state.database.clone()).await?;
Ok((
operators
.iter()
.map(admin::render_admin_user_json)
.collect(),
total,
))
}
async fn detail_context(
state: &AdminState,
target: &AdminUser,
) -> Result<Map<String, Value>, PageError> {
let remaining = mfa::recovery_codes_remaining(target.id, state.database.clone()).await?;
let mut context = Map::new();
context.insert(
"operator".to_string(),
admin::render_admin_user_detail_json(target, remaining),
);
context.insert(
"roles".to_string(),
Value::Array(
AdminRole::ALL
.iter()
.map(|role| Value::from(role.as_str()))
.collect(),
),
);
context.insert(
"sessions".to_string(),
Value::Array(operator_sessions(state, target.id).await?),
);
context.insert(
"sessions_revoke_prefix".to_string(),
Value::String(format!("/ui/operators/{}/sessions", target.username)),
);
context.insert(
"sessions_target".to_string(),
Value::String("#operator-detail".to_string()),
);
context.insert(
"sessions_step_up".to_string(),
Value::String("#operator-step-up-password".to_string()),
);
Ok(context)
}
async fn operator_sessions(
state: &AdminState,
user_id: uuid::Uuid,
) -> Result<Vec<Value>, PageError> {
let page = PageParams::default().resolve(&state.config);
let (sessions, _total) =
AdminSession::search(Some(user_id), page.limit, page.offset, &state.database).await?;
Ok(sessions
.iter()
.map(admin::render_admin_session_json)
.collect())
}