Skip to main content

acme_proxy_admin/webadmin/pages/
accounts.rs

1//! `/ui/accounts` — the account list, one account, and the three things an
2//! operator can do to it.
3//!
4//! Every handler here is a few lines over an `admin::` operation and a
5//! template. The query parameters are `handlers::accounts`' own types, reused
6//! rather than redeclared: the two front ends must accept the same filters, or
7//! a URL copied between them stops meaning the same thing.
8
9use axum::extract::{Path, Query, State};
10use axum::http::StatusCode;
11use axum::response::{Html, Response};
12use serde::Deserialize;
13use serde_json::{Map, Value};
14
15use crate::admin;
16use crate::webadmin::AdminState;
17use crate::webadmin::handlers::Caller;
18use crate::webadmin::handlers::accounts::{
19    AccountListParams, apply_deactivate_account, apply_delete_account, apply_update_account_contact,
20};
21use crate::webadmin::handlers::orders::render_orders;
22use crate::webadmin::handlers::paging::PageParams;
23use crate::webadmin::pages::auth::{PageSession, PageSessionWrite};
24use crate::webadmin::pages::error::{PageError, redirect};
25use crate::webadmin::pages::{
26    ListFilters, chrome, flash, page_value, pager, respond, respond_fragment,
27};
28use acme_proxy_store::account::Account;
29use acme_proxy_store::order::Order;
30use acme_proxy_store::order::OrderQuery;
31
32/// The contact editor posts a textarea, not a JSON array.
33#[derive(Debug, Deserialize)]
34pub struct ContactForm {
35    /// One URI per line. Blank lines are dropped, so clearing the box clears
36    /// the contact list — which is the only way to express "no contact" in a
37    /// textarea.
38    pub contact: String,
39}
40
41/// `GET /ui/accounts?profile=&eabKid=&limit=&offset=`
42pub async fn list_accounts(
43    State(state): State<AdminState>,
44    Query(params): Query<AccountListParams>,
45    session: PageSession,
46) -> Result<Html<String>, PageError> {
47    let page = PageParams::from(params.limit, params.offset).resolve(&state.config);
48    let filters = ListFilters::new()
49        .with("profile", params.profile.as_deref())
50        .with("eabKid", params.eab_kid.as_deref());
51
52    let (accounts, total) = Account::search(
53        params.profile.as_deref(),
54        params.eab_kid.as_deref(),
55        page.limit,
56        page.offset,
57        &state.database,
58    )
59    .await?;
60
61    let items: Vec<Value> = accounts
62        .iter()
63        .map(|account| admin::render_account_json(account, &state.config.server.base_url))
64        .collect();
65
66    let mut context = chrome(&session, "accounts", "Accounts");
67    context.insert("page".to_string(), page_value(items, total));
68    context.insert(
69        "pager".to_string(),
70        pager(
71            page,
72            total,
73            "/ui/accounts",
74            &filters.pairs(),
75            "#accounts-table",
76        ),
77    );
78    context.insert("filters".to_string(), filters.to_value());
79    context.insert(
80        "profiles".to_string(),
81        Value::Array(crate::webadmin::handlers::misc::profile_rows(&state)),
82    );
83
84    respond(
85        &state,
86        session.hx,
87        "accounts/list.html",
88        "accounts/_table.html",
89        context,
90    )
91}
92
93/// `GET /ui/accounts/{id}`
94///
95/// The account and its orders in one response. A separate lazy fetch for the
96/// orders would trade a spinner for the answer an operator is usually here to
97/// get, which is often "none".
98pub async fn get_account(
99    State(state): State<AdminState>,
100    Path(id): Path<String>,
101    Query(params): Query<PageParams>,
102    session: PageSession,
103) -> Result<Html<String>, PageError> {
104    let context = account_page_context(&state, &id, params, &session).await?;
105    respond(
106        &state,
107        session.hx,
108        "accounts/detail.html",
109        "accounts/_card.html",
110        context,
111    )
112}
113
114/// `GET /ui/accounts/{id}/orders?limit=&offset=` — one account's orders.
115///
116/// The swap target of the pager under the account card. It could not be
117/// `GET /ui/accounts/{id}` itself: that URL's fragment is the *card*, so a page
118/// step there swapped a second `#account-card` into `#orders-table`. A
119/// navigation to this URL still gets the whole account page, so the one-URL,
120/// bookmarkable rule holds; the pager simply does not push it, since the
121/// address bar belongs to the account.
122pub async fn list_account_orders(
123    State(state): State<AdminState>,
124    Path(id): Path<String>,
125    Query(params): Query<PageParams>,
126    session: PageSession,
127) -> Result<Html<String>, PageError> {
128    let context = account_page_context(&state, &id, params, &session).await?;
129    respond(
130        &state,
131        session.hx,
132        "accounts/detail.html",
133        "orders/_table.html",
134        context,
135    )
136}
137
138/// The account, one page of its orders, and a pager over them — everything
139/// both account routes above render, whichever half of it they swap.
140async fn account_page_context(
141    state: &AdminState,
142    id: &str,
143    params: PageParams,
144    session: &PageSession,
145) -> Result<Map<String, Value>, PageError> {
146    let account = load(id, state).await?;
147    let page = params.resolve(&state.config);
148
149    let (orders, total) = Order::search(
150        &OrderQuery {
151            account_id: Some(id.to_string()),
152            limit: page.limit,
153            offset: page.offset,
154            ..OrderQuery::default()
155        },
156        &state.database,
157    )
158    .await?;
159    let items = render_orders(&orders, state).await?;
160
161    let mut pager = pager(
162        page,
163        total,
164        &format!("/ui/accounts/{id}/orders"),
165        &[],
166        "#orders-table",
167    );
168    pager["push"] = Value::Bool(false);
169
170    let mut context = chrome(session, "accounts", "Account");
171    context.insert("account".to_string(), account);
172    context.insert("page".to_string(), page_value(items, total));
173    context.insert("order_count".to_string(), Value::from(total));
174    context.insert(
175        "live_certificates".to_string(),
176        Value::from(live_certificates(id, state).await?),
177    );
178    context.insert("pager".to_string(), pager);
179    Ok(context)
180}
181
182/// `POST /ui/accounts/{id}/contact`
183pub async fn post_account_contact(
184    State(state): State<AdminState>,
185    Path(id): Path<String>,
186    session: PageSessionWrite,
187    request_context: acme_proxy_core::audit::RequestContext,
188    axum::Form(form): axum::Form<ContactForm>,
189) -> Result<Html<String>, PageError> {
190    let contact: Vec<String> = form
191        .contact
192        .lines()
193        .map(str::trim)
194        .filter(|line| !line.is_empty())
195        .map(str::to_string)
196        .collect();
197
198    // The same action `PATCH /api/accounts/{id}` runs, whose validator is the
199    // one `newAccount` calls. A refusal is a banner rather than an error page:
200    // the operator is looking at the box they need to correct.
201    match apply_update_account_contact(
202        &state,
203        &Caller::ui(&session.auth, &request_context),
204        &id,
205        contact,
206    )
207    .await
208    {
209        Ok(account) => {
210            let rendered = admin::render_account_json(&account, &state.config.server.base_url);
211            card(&state, &session, rendered, flash("ok", "Contact updated.")).await
212        }
213        Err(error) if error.status == StatusCode::BAD_REQUEST => {
214            let account = load(&id, &state).await?;
215            card(
216                &state,
217                &session,
218                account,
219                super::flash_error(error.code, error.message),
220            )
221            .await
222        }
223        Err(error) => Err(error.into()),
224    }
225}
226
227/// `POST /ui/accounts/{id}/deactivate`
228pub async fn deactivate_account(
229    State(state): State<AdminState>,
230    Path(id): Path<String>,
231    session: PageSessionWrite,
232    request_context: acme_proxy_core::audit::RequestContext,
233) -> Result<Html<String>, PageError> {
234    let account =
235        apply_deactivate_account(&state, &Caller::ui(&session.auth, &request_context), &id).await?;
236
237    let rendered = admin::render_account_json(&account, &state.config.server.base_url);
238    card(
239        &state,
240        &session,
241        rendered,
242        flash(
243            "ok",
244            "Account deactivated. It can no longer request issuance.",
245        ),
246    )
247    .await
248}
249
250/// `DELETE /ui/accounts/{id}`
251///
252/// Answers with a redirect rather than a fragment: the page the button lives on
253/// is the thing that just stopped existing.
254pub async fn delete_account(
255    State(state): State<AdminState>,
256    Path(id): Path<String>,
257    session: PageSessionWrite,
258    request_context: acme_proxy_core::audit::RequestContext,
259) -> Result<Response, PageError> {
260    match apply_delete_account(&state, &Caller::ui(&session.auth, &request_context), &id).await {
261        Ok(_) => {}
262        // The card, with the refusal beside the button that was pressed: the
263        // account is still there, and so is every order that has to be revoked
264        // before it can go.
265        Err(error) if error.status == StatusCode::CONFLICT => {
266            let context = card_context(&state, &session, load(&id, &state).await?).await?;
267            return super::refuse_with_card(&state, "accounts/_card.html", context, &error);
268        }
269        Err(error) => return Err(error.into()),
270    }
271
272    Ok(redirect("/ui/accounts", session.hx))
273}
274
275/// The account card, with a banner — the answer to every account mutation that
276/// leaves the account in place.
277async fn card(
278    state: &AdminState,
279    session: &PageSessionWrite,
280    account: Value,
281    banner: Value,
282) -> Result<Html<String>, PageError> {
283    let mut context = card_context(state, session, account).await?;
284    context.insert("flash".to_string(), banner);
285    respond_fragment(state, "accounts/_card.html", context)
286}
287
288/// Everything the account card reads besides the banner.
289async fn card_context(
290    state: &AdminState,
291    session: &PageSessionWrite,
292    account: Value,
293) -> Result<Map<String, Value>, PageError> {
294    let id = account["id"].as_str().unwrap_or_default().to_string();
295    let mut context = super::fragment_context(&session.auth);
296    context.insert("account".to_string(), account);
297    context.insert(
298        "order_count".to_string(),
299        Value::from(order_count(&id, state).await?),
300    );
301    context.insert(
302        "live_certificates".to_string(),
303        Value::from(live_certificates(&id, state).await?),
304    );
305    Ok(context)
306}
307
308/// How many live certificates the account holds — any of which disables the
309/// card's delete button. The handler refuses regardless; this only spares the
310/// operator a button that can only say no.
311async fn live_certificates(id: &str, state: &AdminState) -> Result<u64, PageError> {
312    let Some(account_id) = acme_proxy_store::id::parse(id) else {
313        return Ok(0);
314    };
315    Ok(Account::count_live_certificates(account_id, &state.database).await?)
316}
317
318/// How many orders a delete of this account would take with it — what the
319/// card's confirmation names, as `account delete`'s prompt does.
320async fn order_count(id: &str, state: &AdminState) -> Result<i64, PageError> {
321    let (_, total) = Order::search(
322        &OrderQuery {
323            account_id: Some(id.to_string()),
324            limit: 1,
325            ..OrderQuery::default()
326        },
327        &state.database,
328    )
329    .await?;
330    Ok(total)
331}
332
333async fn load(id: &str, state: &AdminState) -> Result<Value, PageError> {
334    let account = Account::find_any_by_id(id, &state.database)
335        .await?
336        .ok_or_else(|| not_found(id))?;
337    Ok(admin::render_account_json(
338        &account,
339        &state.config.server.base_url,
340    ))
341}
342
343fn not_found(id: &str) -> PageError {
344    PageError::not_found(crate::admin::subject::Subject::Account.missing(id))
345}