umbral-admin 0.0.3

Auto-generated CRUD admin UI for umbral models.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Per-model bulk-action handlers — both the legacy form-POST path
//! (`run_action`) and the HTMX-friendly per-key dispatch
//! (`dispatch_action`). The per-model `Action` set comes from the
//! developer's `AdminModel::actions(...)` config; this module just
//! resolves the right `Action` and invokes its handler.

use std::sync::Arc;

use axum::extract::{Path, State};
use umbral::web::{HeaderMap, IntoResponse, Redirect, Response, StatusCode};

use crate::AdminState;
use crate::auth::require_staff;
use crate::config::{ActionInvocation, ActionResult, ActionScope, ActionVariant, AdminConfig};
use crate::error::AdminError;
use crate::util::urlencoding_simple;

/// `POST /admin/{table}/action` — legacy form-POST entry point. The
/// changelist's bulk action `<form>` posts here; the response is a
/// Redirect with a `?flash=...` toast.
pub(crate) async fn run_action(
    State(state): State<AdminState>,
    headers: HeaderMap,
    Path(table): Path<String>,
    body: String,
) -> Response {
    let path = format!("{}/{table}/action", crate::branding::current().base_path);
    let who = match require_staff(&headers, &path).await {
        Ok(u) => u,
        Err(r) => return r,
    };
    // WEB-7: same model-level gate as `dispatch_action` — the legacy
    // form-POST path runs the same mutating bulk-action handlers.
    let Some((plugin_name, _model)) = crate::discovery::find_model(&table) else {
        return AdminError::NotFound(format!("no model `{table}`")).into_response();
    };
    if let Err(r) =
        crate::permcheck::require(&who, &plugin_name, &table, crate::permcheck::Action::Change)
            .await
    {
        return r;
    }
    let pairs: Vec<(String, String)> = match serde_urlencoded::from_str(&body) {
        Ok(m) => m,
        Err(e) => return AdminError::BadInput(e.to_string()).into_response(),
    };
    let action_key = pairs
        .iter()
        .find(|(k, _)| k == "action")
        .map(|(_, v)| v.clone())
        .unwrap_or_default();
    let selected_ids: Vec<String> = pairs
        .iter()
        .filter(|(k, _)| k.as_str() == "selected")
        .map(|(_, v)| v.clone())
        .collect();

    let cfg = state.config_for(&table);
    // gaps2 #35: a soft-delete model resolves the built-in trash actions
    // (`restore_selected`, `delete_permanently`) in addition to whatever
    // the developer configured — they're injected into the changelist's
    // trash view but dispatched through this same endpoint.
    let actions = resolve_actions(cfg, &table);
    let action = actions.iter().find(|a| a.key() == action_key);
    let Some(action) = action else {
        return AdminError::NotFound(format!("no action `{action_key}` for table `{table}`"))
            .into_response();
    };

    // gaps2 #79: enforce Action::permission before running the handler.
    // Superusers bypass the check; non-superusers need the exact codename.
    if let Some(ref required_perm) = action.permission {
        if let Err(r) = check_action_perm(&who, required_perm).await {
            return r;
        }
    }
    // gaps2 #35: the built-in "Delete permanently" is a hard delete, so
    // it needs the stronger `delete_<model>` permission — the broad
    // Change gate above isn't enough for an irreversible removal.
    if action.key() == "delete_permanently" {
        if let Err(r) = crate::permcheck::require(
            &who,
            &plugin_name,
            &table,
            crate::permcheck::Action::Delete,
        )
        .await
        {
            return r;
        }
    }

    let inv = ActionInvocation {
        ids: selected_ids.clone(),
        username: who.username.clone(),
        table: table.clone(),
        pool: umbral::db::pool_dispatched().clone(),
    };
    let handler = Arc::clone(&action.handler);
    let result = handler(inv).await;
    // Audit log — one entry per bulk-action submission.
    let summary = match &result {
        Ok(_) => format!(
            "ran action `{}` on {} #{:?} (via form)",
            action_key, table, selected_ids
        ),
        Err(e) => format!("action `{action_key}` on {table} failed: {e}"),
    };
    crate::models::log(
        who.id,
        &format!("action:{action_key}"),
        &table,
        selected_ids.first().and_then(|s| s.parse::<i64>().ok()),
        &summary,
    )
    .await;
    let flash = match result {
        Ok(ActionResult::Toast { message, .. }) => message,
        Ok(ActionResult::RefreshTable) => "Done.".to_string(),
        Ok(_) => "Done.".to_string(),
        Err(e) => {
            tracing::error!(error = %e, "admin: action `{action_key}` failed");
            return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response();
        }
    };
    let location = format!(
        "{}/{table}/?flash={}",
        crate::branding::current().base_path,
        urlencoding_simple(&flash)
    );
    Redirect::to(&location).into_response()
}

/// Serialise an action slice for the template's "Actions" menu — the
/// bulk-action toolbar and the per-row chip strip. The changelist passes
/// the effective set (config + soft-delete trash built-ins) computed via
/// [`crate::config::effective_actions`].
pub(crate) fn descriptors_for(actions: &[crate::config::Action]) -> Vec<serde_json::Value> {
    actions
        .iter()
        .map(|a| {
            serde_json::json!({
                "key":     a.key,
                "label":   a.label,
                "icon":    a.icon,
                "variant": match a.variant { ActionVariant::Danger => "danger", _ => "default" },
                "scope":   match a.scope { ActionScope::Row => "row", ActionScope::Bulk => "bulk", ActionScope::Both => "both" },
                "confirm": a.confirm,
            })
        })
        .collect()
}

/// `POST /admin/{table}/actions/{key}` — HTMX-friendly per-key action
/// dispatch. Body can be either JSON `{"ids":[...]}` or form-encoded
/// `ids=&ids=`. The response encodes the `ActionResult` variant as an
/// `HX-Trigger` header so the front-end can react without a full page
/// reload (toast, refresh table, open sheet, download, redirect).
pub(crate) async fn dispatch_action(
    State(state): State<AdminState>,
    headers: HeaderMap,
    Path((table, key)): Path<(String, String)>,
    body: String,
) -> Response {
    let path = format!(
        "{}/{table}/actions/{key}",
        crate::branding::current().base_path
    );
    let who = match require_staff(&headers, &path).await {
        Ok(u) => u,
        Err(r) => return r,
    };

    // WEB-7: bulk actions run developer-defined handlers that can mutate
    // or delete rows, so they need the same model-level permission gate as
    // the CRUD handlers — `require_staff` alone lets any staff user fire
    // them regardless of `change_<model>`. Gate on Change (the broadest
    // thing an action can do); a no-permissions install (no
    // umbral-permissions) still passes, matching the rest of the admin.
    let Some((plugin_name, _model)) = crate::discovery::find_model(&table) else {
        return AdminError::NotFound(format!("no model `{table}`")).into_response();
    };
    if let Err(r) =
        crate::permcheck::require(&who, &plugin_name, &table, crate::permcheck::Action::Change)
            .await
    {
        return r;
    }

    let ids: Vec<String> = if body.trim_start().starts_with('{') {
        match serde_json::from_str::<serde_json::Value>(&body) {
            Ok(v) => v["ids"]
                .as_array()
                .map(|arr| {
                    arr.iter()
                        .filter_map(|x| {
                            // Accept both JSON numbers and JSON strings so callers
                            // can send either `{"ids":[1,2]}` or `{"ids":["a","b"]}`.
                            x.as_str()
                                .map(|s| s.to_string())
                                .or_else(|| x.as_i64().map(|n| n.to_string()))
                        })
                        .collect()
                })
                .unwrap_or_default(),
            Err(e) => return AdminError::BadInput(format!("bad JSON: {e}")).into_response(),
        }
    } else {
        let pairs: Vec<(String, String)> =
            serde_urlencoded::from_str(&body).unwrap_or_default();
        pairs
            .into_iter()
            .filter(|(k, _)| k.as_str() == "ids" || k.as_str() == "selected")
            .map(|(_, v)| v)
            .collect()
    };

    let cfg = state.config_for(&table);
    // gaps2 #35: include the soft-delete trash built-ins (see `run_action`).
    let actions = resolve_actions(cfg, &table);
    let action = actions.iter().find(|a| a.key() == key);
    let Some(action) = action else {
        return AdminError::NotFound(format!("no action `{key}` for `{table}`")).into_response();
    };

    // gaps2 #79: enforce Action::permission before running the handler.
    // Superusers bypass the check; non-superusers need the exact codename.
    if let Some(ref required_perm) = action.permission {
        if let Err(r) = crate::handlers::actions::check_action_perm(&who, required_perm).await {
            return r;
        }
    }
    // gaps2 #35: the built-in "Delete permanently" hard-deletes, so it
    // needs `delete_<model>` — stronger than the broad Change gate above.
    if action.key() == "delete_permanently" {
        if let Err(r) = crate::permcheck::require(
            &who,
            &plugin_name,
            &table,
            crate::permcheck::Action::Delete,
        )
        .await
        {
            return r;
        }
    }

    let inv = ActionInvocation {
        ids: ids.clone(),
        username: who.username.clone(),
        table: table.clone(),
        pool: umbral::db::pool_dispatched().clone(),
    };
    let handler = Arc::clone(&action.handler);
    let result = handler(inv).await;
    // Audit log — one entry per dispatched action regardless of
    // outcome variant, so the timeline shows what was invoked and
    // by whom even when the action returns a Download or Redirect.
    let summary = match &result {
        Ok(_) => format!(
            "ran action `{}` on {} #{:?} (via dispatch)",
            key, table, ids
        ),
        Err(e) => format!("action `{key}` on {table} failed: {e}"),
    };
    crate::models::log(
        who.id,
        &format!("action:{key}"),
        &table,
        ids.first().and_then(|s| s.parse::<i64>().ok()),
        &summary,
    )
    .await;
    match result {
        Ok(ActionResult::Toast { message, level }) => {
            let trigger = serde_json::json!({
                "showToast": { "message": message, "level": level.as_str() }
            });
            axum::response::Response::builder()
                .status(StatusCode::OK)
                .header("HX-Trigger", trigger.to_string())
                .body(axum::body::Body::empty())
                .unwrap_or_else(|_| StatusCode::OK.into_response())
        }
        Ok(ActionResult::RefreshTable) => {
            let trigger = serde_json::json!({ "refreshTable": {} });
            axum::response::Response::builder()
                .status(StatusCode::OK)
                .header("HX-Trigger", trigger.to_string())
                .body(axum::body::Body::empty())
                .unwrap_or_else(|_| StatusCode::OK.into_response())
        }
        Ok(ActionResult::OpenSheet { table: t, id }) => {
            let trigger = serde_json::json!({ "openSheet": { "table": t, "id": id } });
            axum::response::Response::builder()
                .status(StatusCode::OK)
                .header("HX-Trigger", trigger.to_string())
                .body(axum::body::Body::empty())
                .unwrap_or_else(|_| StatusCode::OK.into_response())
        }
        Ok(ActionResult::Download {
            filename,
            content_type,
            bytes,
        }) => axum::response::Response::builder()
            .status(StatusCode::OK)
            .header("Content-Type", content_type)
            .header(
                "Content-Disposition",
                format!("attachment; filename=\"{filename}\""),
            )
            .body(axum::body::Body::from(bytes))
            .unwrap_or_else(|_| StatusCode::OK.into_response()),
        Ok(ActionResult::Redirect { url }) => axum::response::Response::builder()
            .status(StatusCode::OK)
            .header("HX-Redirect", url)
            .body(axum::body::Body::empty())
            .unwrap_or_else(|_| StatusCode::OK.into_response()),
        Err(e) => {
            tracing::error!(error = %e, "admin: action `{key}` failed");
            let trigger = serde_json::json!({
                "showToast": { "message": e, "level": "error" }
            });
            axum::response::Response::builder()
                .status(StatusCode::INTERNAL_SERVER_ERROR)
                .header("HX-Trigger", trigger.to_string())
                .body(axum::body::Body::empty())
                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
        }
    }
}

/// Resolve the dispatchable action set for `table` (gaps2 #35).
///
/// The developer's configured actions, plus — for a `soft_delete`
/// model — the built-in trash actions (`restore_selected`,
/// `delete_permanently`). The built-ins are appended only when not
/// already present so a developer who lists them explicitly doesn't get
/// duplicates. A non-soft-delete model returns its configured set
/// verbatim.
pub(crate) fn resolve_actions(
    cfg: Option<&AdminConfig>,
    table: &str,
) -> Vec<crate::config::Action> {
    let mut actions: Vec<crate::config::Action> =
        cfg.map(|c| c.actions.clone()).unwrap_or_default();
    let soft_delete = crate::discovery::find_model(table)
        .map(|(_, meta)| meta.soft_delete)
        .unwrap_or(false);
    if soft_delete {
        for builtin in [
            crate::config::Action::restore_selected(),
            crate::config::Action::delete_permanently(),
        ] {
            if !actions.iter().any(|a| a.key() == builtin.key()) {
                actions.push(builtin);
            }
        }
    }
    actions
}

/// Check that `who` holds the required action permission codename.
///
/// Mirrors `permcheck::check` but operates on a raw codename string
/// (as stored in `Action::permission`) rather than deriving one from
/// (plugin, table, verb).  Superusers always pass; the check is a
/// no-op when `umbral-permissions` is not installed.
pub(crate) async fn check_action_perm(
    who: &umbral_auth::AuthUser,
    required_perm: &str,
) -> Result<(), Response> {
    // No-op when the permissions plugin isn't installed (matches the
    // rest of the admin's graceful-fallback behaviour from permcheck.rs).
    if !crate::permcheck::permissions_installed() {
        return Ok(());
    }
    let user_id = who.id.to_string();
    let allowed = umbral_permissions::has_perm_for_superuser(
        &user_id,
        who.is_superuser,
        required_perm,
    )
    .await
    .unwrap_or_else(|err| {
        tracing::warn!(
            user_id = user_id.as_str(),
            perm = required_perm,
            error = %err,
            "action permission check failed; denying by default"
        );
        false
    });
    if allowed {
        Ok(())
    } else {
        Err((
            StatusCode::FORBIDDEN,
            "umbral-admin: permission denied for this action",
        )
            .into_response())
    }
}