1use axum::extract::{Path, Query, State};
12use axum::response::{Html, IntoResponse, Response};
13use serde::Deserialize;
14use serde_json::{Map, Value};
15
16use crate::admin;
17use crate::admin::{mfa, users};
18use crate::webadmin::AdminState;
19use crate::webadmin::error::AdminError;
20use crate::webadmin::handlers::mfa::verify_current_password;
21use crate::webadmin::handlers::operators::{
22 OperatorAction, apply_operator_action, find, refuse_self_target,
23};
24use crate::webadmin::handlers::paging::{Page, PageParams};
25use crate::webadmin::pages::auth::{PageAdminRead, PageAdminWrite};
26use crate::webadmin::pages::error::{PageError, redirect};
27use crate::webadmin::pages::{chrome, flash, page_value, pager, respond, respond_fragment};
28use crate::webadmin::session::AdminClientIp;
29use acme_proxy_store::admin_session::AdminSession;
30use acme_proxy_store::admin_user::AdminRole;
31use acme_proxy_store::admin_user::AdminUser;
32
33#[derive(Debug, Default, Deserialize)]
37pub struct StepUpForm {
38 #[serde(default)]
39 pub password: String,
40}
41
42pub async fn list_operators(
44 State(state): State<AdminState>,
45 Query(params): Query<PageParams>,
46 session: PageAdminRead,
47) -> Result<Html<String>, PageError> {
48 let page = params.resolve(&state.config);
49 let (operators, total) = rows(page, &state).await?;
50
51 let mut context = chrome(&session, "operators", "Operators");
52 context.insert("page".to_string(), page_value(operators, total));
53 context.insert(
54 "pager".to_string(),
55 pager(page, total, "/ui/operators", &[], "#operators-table"),
56 );
57
58 respond(
59 &state,
60 session.hx,
61 "operators/list.html",
62 "operators/_table.html",
63 context,
64 )
65}
66
67pub async fn get_operator(
70 State(state): State<AdminState>,
71 Path(username): Path<String>,
72 session: PageAdminRead,
73) -> Result<Response, PageError> {
74 let target = find(&username, &state).await?;
75 if target.id == session.auth.user.id {
76 return Ok(redirect("/ui/account", session.hx));
77 }
78
79 let mut context = chrome(&session, "operators", "Operator");
80 for (key, value) in detail_context(&state, &target).await? {
81 context.insert(key, value);
82 }
83
84 Ok(respond(
85 &state,
86 session.hx,
87 "operators/detail.html",
88 "operators/_card.html",
89 context,
90 )?
91 .into_response())
92}
93pub async fn disable_operator(
95 State(state): State<AdminState>,
96 Path(username): Path<String>,
97 AdminClientIp(client): AdminClientIp,
98 session: PageAdminWrite,
99 request_context: acme_proxy_core::audit::RequestContext,
100 axum::Form(body): axum::Form<StepUpForm>,
101) -> Result<Response, PageError> {
102 act(
103 &state,
104 &session,
105 &username,
106 &body.password,
107 client,
108 &request_context,
109 OperatorAction::SetStatus { active: false },
110 flash("ok", "Operator disabled. Their sessions were revoked."),
111 )
112 .await
113}
114
115pub async fn enable_operator(
117 State(state): State<AdminState>,
118 Path(username): Path<String>,
119 AdminClientIp(client): AdminClientIp,
120 session: PageAdminWrite,
121 request_context: acme_proxy_core::audit::RequestContext,
122 axum::Form(body): axum::Form<StepUpForm>,
123) -> Result<Response, PageError> {
124 act(
125 &state,
126 &session,
127 &username,
128 &body.password,
129 client,
130 &request_context,
131 OperatorAction::SetStatus { active: true },
132 flash("ok", "Operator enabled."),
133 )
134 .await
135}
136
137pub async fn reset_operator_totp(
139 State(state): State<AdminState>,
140 Path(username): Path<String>,
141 AdminClientIp(client): AdminClientIp,
142 session: PageAdminWrite,
143 request_context: acme_proxy_core::audit::RequestContext,
144 axum::Form(body): axum::Form<StepUpForm>,
145) -> Result<Response, PageError> {
146 act(
147 &state,
148 &session,
149 &username,
150 &body.password,
151 client,
152 &request_context,
153 OperatorAction::ResetTotp,
154 flash(
155 "warn",
156 "Their second factor and recovery codes were removed. They can \
157 sign in with a password alone until they enrol again.",
158 ),
159 )
160 .await
161}
162
163pub async fn revoke_operator_session(
165 State(state): State<AdminState>,
166 Path((username, id)): Path<(String, String)>,
167 AdminClientIp(client): AdminClientIp,
168 session: PageAdminWrite,
169 request_context: acme_proxy_core::audit::RequestContext,
170 axum::Form(body): axum::Form<StepUpForm>,
171) -> Result<Response, PageError> {
172 act(
173 &state,
174 &session,
175 &username,
176 &body.password,
177 client,
178 &request_context,
179 OperatorAction::RevokeSession { fingerprint: &id },
180 flash("ok", "Session revoked."),
181 )
182 .await
183}
184
185#[derive(Debug, Default, Deserialize)]
188pub struct OperatorContactForm {
189 #[serde(default)]
190 pub password: String,
191 #[serde(default)]
192 pub contact: String,
193}
194
195#[derive(Debug, Default, Deserialize)]
197pub struct OperatorRoleForm {
198 #[serde(default)]
199 pub password: String,
200 #[serde(default)]
201 pub role: String,
202}
203
204pub async fn set_operator_contact(
206 State(state): State<AdminState>,
207 Path(username): Path<String>,
208 AdminClientIp(client): AdminClientIp,
209 session: PageAdminWrite,
210 request_context: acme_proxy_core::audit::RequestContext,
211 axum::Form(form): axum::Form<OperatorContactForm>,
212) -> Result<Response, PageError> {
213 let banner = if form.contact.trim().is_empty() {
214 flash(
215 "warn",
216 "Their notification address was cleared: security notifications cannot reach them.",
217 )
218 } else {
219 flash(
220 "ok",
221 "Their notification address was changed. The previous one was told.",
222 )
223 };
224 act(
225 &state,
226 &session,
227 &username,
228 &form.password,
229 client,
230 &request_context,
231 OperatorAction::SetContact {
232 contact: Some(form.contact.as_str()),
233 },
234 banner,
235 )
236 .await
237}
238
239pub async fn set_operator_role(
241 State(state): State<AdminState>,
242 Path(username): Path<String>,
243 AdminClientIp(client): AdminClientIp,
244 session: PageAdminWrite,
245 request_context: acme_proxy_core::audit::RequestContext,
246 axum::Form(form): axum::Form<OperatorRoleForm>,
247) -> Result<Response, PageError> {
248 let role = match form.role.parse::<AdminRole>() {
249 Ok(role) => role,
250 Err(message) => {
255 let target = find(&username, &state).await?;
256 refuse_self_target(&session.auth.user, &target)?;
257 let context = detail_context(&state, &target).await?;
258 return super::refuse_with_card(
259 &state,
260 "operators/_card.html",
261 context,
262 &AdminError::bad_request(message),
263 );
264 }
265 };
266 act(
267 &state,
268 &session,
269 &username,
270 &form.password,
271 client,
272 &request_context,
273 OperatorAction::SetRole { role },
274 flash(
275 "ok",
276 format!(
277 "Role changed to {}. Their sessions were revoked.",
278 role.as_str()
279 ),
280 ),
281 )
282 .await
283}
284
285#[allow(clippy::too_many_arguments)]
293async fn act(
294 state: &AdminState,
295 session: &PageAdminWrite,
296 username: &str,
297 password: &str,
298 client: Option<std::net::IpAddr>,
299 request_context: &acme_proxy_core::audit::RequestContext,
300 action: OperatorAction<'_>,
301 banner: Value,
302) -> Result<Response, PageError> {
303 let mut target = find(username, state).await?;
304 refuse_self_target(&session.auth.user, &target)?;
305 if let Some(refusal) =
306 refuse_without_password(state, session, &target, password, client).await?
307 {
308 return Ok(refusal);
309 }
310
311 if let Err(error) = apply_operator_action(
312 state,
313 &session.auth.user,
314 &mut target,
315 action,
316 client,
317 request_context,
318 "ui",
319 )
320 .await
321 {
322 if error.status.is_client_error() && error.status != axum::http::StatusCode::NOT_FOUND {
327 let context = detail_context(state, &target).await?;
328 return super::refuse_with_card(state, "operators/_card.html", context, &error);
329 }
330 return Err(error.into());
331 }
332
333 respond_card(state, &target, banner).await
334}
335
336async fn refuse_without_password(
344 state: &AdminState,
345 session: &PageAdminWrite,
346 target: &AdminUser,
347 password: &str,
348 client: Option<std::net::IpAddr>,
349) -> Result<Option<Response>, PageError> {
350 let Err(error) =
351 verify_current_password(&session.auth.user, password, client, &state.logins).await
352 else {
353 return Ok(None);
354 };
355 let context = detail_context(state, target).await?;
356 Ok(Some(super::refuse_with_card(
357 state,
358 "operators/_card.html",
359 context,
360 &error,
361 )?))
362}
363
364async fn respond_card(
371 state: &AdminState,
372 target: &AdminUser,
373 banner: Value,
374) -> Result<Response, PageError> {
375 let mut context = detail_context(state, target).await?;
376 context.insert("flash".to_string(), banner);
377 Ok(respond_fragment(state, "operators/_card.html", context)?.into_response())
378}
379
380async fn rows(page: Page, state: &AdminState) -> Result<(Vec<Value>, i64), PageError> {
381 let (operators, total) =
382 users::list_users(page.limit, page.offset, state.database.clone()).await?;
383 Ok((
384 operators
385 .iter()
386 .map(admin::render_admin_user_json)
387 .collect(),
388 total,
389 ))
390}
391
392async fn detail_context(
396 state: &AdminState,
397 target: &AdminUser,
398) -> Result<Map<String, Value>, PageError> {
399 let remaining = mfa::recovery_codes_remaining(target.id, state.database.clone()).await?;
400 let mut context = Map::new();
401 context.insert(
402 "operator".to_string(),
403 admin::render_admin_user_detail_json(target, remaining),
404 );
405 context.insert(
408 "roles".to_string(),
409 Value::Array(
410 AdminRole::ALL
411 .iter()
412 .map(|role| Value::from(role.as_str()))
413 .collect(),
414 ),
415 );
416 context.insert(
417 "sessions".to_string(),
418 Value::Array(operator_sessions(state, target.id).await?),
419 );
420 context.insert(
421 "sessions_revoke_prefix".to_string(),
422 Value::String(format!("/ui/operators/{}/sessions", target.username)),
423 );
424 context.insert(
425 "sessions_target".to_string(),
426 Value::String("#operator-detail".to_string()),
427 );
428 context.insert(
433 "sessions_step_up".to_string(),
434 Value::String("#operator-step-up-password".to_string()),
435 );
436 Ok(context)
437}
438
439async fn operator_sessions(
443 state: &AdminState,
444 user_id: uuid::Uuid,
445) -> Result<Vec<Value>, PageError> {
446 let page = PageParams::default().resolve(&state.config);
447 let (sessions, _total) =
448 AdminSession::search(Some(user_id), page.limit, page.offset, &state.database).await?;
449 Ok(sessions
450 .iter()
451 .map(admin::render_admin_session_json)
452 .collect())
453}