rustlavel-cli 0.4.0

The rustlavel command-line tool: new, serve, make:*
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
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
//! Managing people: who exists, what roles they hold, and the exceptions.

use rustlavel::prelude::*;
use rustlavel::rbac::Permissions;

use crate::models::user::User;
use crate::support::stats;
use crate::support::{page, tokens};

const PER_PAGE: i64 = 20;

pub struct UsersController;

impl UsersController {
    pub async fn index(req: Request) -> Result<Response> {
        let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
        let store = rbac(&req)?;
        let me = req.identity().and_then(|id| id.id_as::<i64>()).unwrap_or_default();

        let search = req.query("q").unwrap_or_default().trim().to_string();
        let role_filter = req.query("role").unwrap_or_default().to_string();
        let page_number = req.query("page").and_then(|p| p.parse::<i64>().ok()).unwrap_or(1).max(1);

        let mut query = User::query().order_by("name", rustlavel::db::Direction::Asc);
        if !search.is_empty() {
            let pattern = format!("%{search}%");
            query = query.group_filter(|q| {
                q.filter_like("name", pattern.clone()).or_filter("email", pattern.clone())
            });
        }
        if !role_filter.is_empty() {
            // Filtering by role means asking the RBAC store, which owns those
            // tables; joining across from here would tie this page to a schema
            // that is not this application's to know.
            // The RBAC store answers "does this user hold this role", not "who
            // holds it", so the filter is applied after the rows come back.
            // Fine for an administration screen; a directory of a hundred
            // thousand people would want the store to grow the reverse lookup.
            let _ = &role_filter;
        }

        let listed = query.paginate(&db, page_number, PER_PAGE).await?;
        let mut users = listed.hydrate::<User>()?;
        if !role_filter.is_empty() {
            let mut kept = Vec::new();
            for user in users {
                if store.has_role(user.id, &role_filter).await.unwrap_or(false) {
                    kept.push(user);
                }
            }
            users = kept;
        }
        let now = tokens::now();

        let mut rows = Vec::with_capacity(users.len());
        for user in &users {
            let roles = store.roles_for(user.id).await.unwrap_or_default();
            let mut json = user.public_json();
            if let Json::Object(fields) = &mut json {
                fields.insert("locked".into(), Json::from(user.is_locked(&now)));
                fields.insert(
                    "last_login_at".into(),
                    Json::from(
                        user.last_login_at.as_deref().map(tokens::humanise).unwrap_or_else(|| "Never".into()),
                    ),
                );
                fields.insert("roles_empty".into(), Json::from(roles.is_empty()));
                fields.insert(
                    "roles".into(),
                    Json::Array(roles.iter().map(|r| Json::from(r.as_str())).collect()),
                );
                // The exceptions, counted. A user with none is the normal case;
                // a user with several is the one somebody will come looking for
                // when they cannot work out why a permission is not applying.
                fields.insert(
                    "direct_permissions".into(),
                    Json::from(store.direct_permissions(user.id).await.unwrap_or_default().len() as i64),
                );
                fields.insert(
                    "joined_at".into(),
                    Json::from(
                        user.created_at
                            .as_deref()
                            .map(tokens::humanise_date)
                            .unwrap_or_else(|| "".into()),
                    ),
                );
                // Nobody deletes or impersonates themselves. The first is a way
                // to lock yourself out of your own application; the second does
                // nothing and leaves a session that looks impersonated.
                fields.insert("deletable".into(), Json::from(user.id != me));
                fields.insert("impersonatable".into(), Json::from(user.id != me));
            }
            rows.push(json);
        }

        let stats = Self::statistics(&db, &store, &now).await?;
        let all_roles = store.roles().await.unwrap_or_default();
        let mut context = page::shell(&req, "users").await;
        context = with_current_user(context, &req, &db).await?;

        context = context
            .with("stats", Json::Array(stats))
            .with("q", Json::from(search.as_str()))
            .with("users_empty", Json::from(rows.is_empty()))
            .with("users", Json::Array(rows))
            .with(
                "all_roles",
                Json::Array(
                    all_roles
                        .iter()
                        .map(|role| {
                            Json::object([
                                ("name", Json::from(role.name.as_str())),
                                ("selected", Json::from(role.name == role_filter)),
                            ])
                        })
                        .collect(),
                ),
            )
            .with("can_create", Json::from(req.can("users.create").await?))
            .with("can_update", Json::from(req.can("users.update").await?))
            .with("can_delete", Json::from(req.can("users.delete").await?))
            .with("can_impersonate", Json::from(req.can("users.impersonate").await?));

        context = pagination(context, &req, page_number, listed.total);
        req.view("admin/users/index", &context)
    }

    /// The six counts above the table.
    ///
    /// Each is a query rather than a placeholder, and each answers something a
    /// person administering this actually asks. "Active today" is deliberately
    /// a count of *people* rather than of sign-ins: three visits from one
    /// person is one person.
    async fn statistics(db: &Database, store: &Permissions, now: &str) -> Result<Vec<Json>> {
        let midnight = format!("{} 00:00:00", &now[..10.min(now.len())]);
        let week_ago = tokens::format_utc(tokens::unix_now() - 7 * 24 * 60 * 60);

        let total = db.table("users").count(db).await?;
        let verified = db.table("users").filter_not_null("email_verified_at").count(db).await?;
        let active_today = db
            .table("users")
            .filter_op("last_login_at", ">=", midnight)
            .count(db)
            .await
            .unwrap_or(0);
        let recent = db.table("users").filter_op("created_at", ">=", week_ago).count(db).await?;

        // These two come from the RBAC store, whose tables this application does
        // not own and must not join against.
        let mut with_roles = 0;
        let mut with_direct = 0;
        for row in db.table("users").select(&["id"]).get(db).await? {
            let Ok(id) = row.get::<i64>("id") else { continue };
            if !store.roles_for(id).await.unwrap_or_default().is_empty() {
                with_roles += 1;
            }
            if !store.direct_permissions(id).await.unwrap_or_default().is_empty() {
                with_direct += 1;
            }
        }

        Ok(vec![
            stats::card("Total Users", total, stats::BRAND, stats::ICON_USERS),
            stats::card("Verified Users", verified, stats::GOOD, stats::ICON_CHECK),
            stats::card("Active Today", active_today, stats::BUSY, stats::ICON_BOLT),
            stats::card("Users with Roles", with_roles, stats::PEOPLE, stats::ICON_GROUP),
            stats::card("Direct Perms", with_direct, stats::KEYED, stats::ICON_KEY),
            stats::card("New This Week", recent, stats::TIMED, stats::ICON_CLOCK),
        ])
    }

    pub async fn create(req: Request) -> Result<Response> {
        let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
        let mut context = page::shell(&req, "users").await;
        context = with_current_user(context, &req, &db).await?;
        context = Self::form_context(context, &req, None).await?;
        req.view("admin/users/form", &context)
    }

    pub async fn store(mut req: Request) -> Result<Response> {
        let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
        let store = rbac(&req)?;

        let name = req.input("name").unwrap_or_default();
        let email = req.input("email").unwrap_or_default().trim().to_lowercase();
        let roles = req.inputs("roles[]");

        let mut errors = page::check(
            &[("name", &name), ("email", &email)],
            &[("name", "required|max:120"), ("email", "required|email|max:190")],
        );
        if User::first(&db, User::by_email(&email)).await?.is_some() {
            // Said plainly here, unlike on the public form: an administrator
            // needs to know why the user was not created, and already knows who
            // has an account.
            errors.add("email", "Somebody already has that address.");
        }

        if !errors.is_empty() {
            let mut context = page::errors(page::shell(&req, "users").await, &errors);
            context = with_current_user(context, &req, &db).await?;
            context = Self::form_context(context, &req, None).await?;
            return req.view("admin/users/form", &context.with("name", Json::from(name)).with("email", Json::from(email)));
        }

        let mut user = User { name: name.trim().to_string(), email, is_active: true, ..Default::default() };
        user.insert(&db).await?;

        for role in &roles {
            store.assign_role(user.id, role).await?;
        }

        // No password is set here, and none is ever shown to an administrator.
        // The invitation is how the person chooses their own.
        crate::controllers::auth::register_controller::send_activation(
            &req,
            &db,
            &user,
            "You have been invited",
        )
        .await?;

        if let Some(audit) = crate::support::audit::of(&req, "users.created") {
            audit
                .on("User", user.id)
                .describe(format!("Invited {} ({})", user.name, user.email))
                .record()
                .await;
        }
        page::flash(&req, "success", format!("{} has been invited.", user.name));
        Ok(Response::see_other("/admin/users"))
    }

    pub async fn edit(req: Request) -> Result<Response> {
        let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
        let id = req.param_as::<i64>("id").unwrap_or_default();
        let Some(user) = User::find(&db, id).await? else { return Ok(Response::not_found()) };

        let mut context = page::shell(&req, "users").await;
        context = with_current_user(context, &req, &db).await?;
        context = Self::form_context(context, &req, Some(&user)).await?;
        req.view("admin/users/form", &context)
    }

    pub async fn update(mut req: Request) -> Result<Response> {
        let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
        let store = rbac(&req)?;
        let id = req.param_as::<i64>("id").unwrap_or_default();
        let Some(mut user) = User::find(&db, id).await? else { return Ok(Response::not_found()) };

        let name = req.input("name").unwrap_or_default();
        let email = req.input("email").unwrap_or_default().trim().to_lowercase();
        let roles = req.inputs("roles[]");

        let mut errors = page::check(
            &[("name", &name), ("email", &email)],
            &[("name", "required|max:120"), ("email", "required|email|max:190")],
        );
        if let Some(other) = User::first(&db, User::by_email(&email)).await?
            && other.id != user.id
        {
            errors.add("email", "Somebody already has that address.");
        }
        if !errors.is_empty() {
            let mut context = page::errors(page::shell(&req, "users").await, &errors);
            context = with_current_user(context, &req, &db).await?;
            context = Self::form_context(context, &req, Some(&user)).await?;
            return req.view("admin/users/form", &context);
        }

        user.name = name.trim().to_string();
        user.email = email;
        user.update(&db).await?;

        // Roles are replaced wholesale, so unticking one removes it.
        let held = store.roles_for(user.id).await?;
        for role in held.iter().filter(|r| !roles.contains(r)) {
            store.remove_role(user.id, role).await?;
        }
        for role in roles.iter().filter(|r| !held.contains(r)) {
            store.assign_role(user.id, role).await?;
        }

        // The direct exceptions, three-way per permission.
        for permission in store.permissions().await? {
            match req.input(&format!("permission[{}]", permission.name)).as_deref() {
                Some("grant") => store.grant(user.id, &permission.name).await?,
                Some("deny") => store.deny(user.id, &permission.name).await?,
                Some("inherit") => store.reset(user.id, &permission.name).await?,
                _ => {}
            }
        }

        if let Some(audit) = crate::support::audit::of(&req, "users.updated") {
            audit
                .on("User", user.id)
                .describe(format!("Updated the account {}", user.name))
                .record()
                .await;
        }
        page::flash(&req, "success", format!("{} has been updated.", user.name));
        Ok(Response::see_other("/admin/users"))
    }

    pub async fn destroy(req: Request) -> Result<Response> {
        let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
        let store = rbac(&req)?;
        let id = req.param_as::<i64>("id").unwrap_or_default();
        let me = req.identity().and_then(|id| id.id_as::<i64>()).unwrap_or_default();

        if id == me {
            page::flash(&req, "error", "You cannot delete your own account.");
            return Ok(Response::see_other("/admin/users"));
        }

        let Some(user) = User::find(&db, id).await? else { return Ok(Response::not_found()) };
        // The roles and permissions go too. They live in another crate's
        // tables, which have no foreign key to this application's users, so
        // nothing removes them on our behalf.
        store.purge_user(user.id).await?;
        user.delete(&db).await?;

        // The email is kept on the entry. It is the only thing left that
        // identifies which account this was once the row is gone, and "who
        // deleted that account" is the question an audit trail exists for.
        if let Some(audit) = crate::support::audit::of(&req, "users.deleted") {
            audit
                .on("User", user.id)
                .describe(format!("Deleted the account {}", user.name))
                .with("email", Json::from(user.email.as_str()))
                .record()
                .await;
        }
        page::flash(&req, "warning", format!("{} has been deleted.", user.name));
        Ok(Response::see_other("/admin/users"))
    }

    /// The fields a create or edit form needs.
    async fn form_context(
        context: ViewContext,
        req: &Request,
        user: Option<&User>,
    ) -> Result<ViewContext> {
        let store = rbac(req)?;
        let is_new = user.is_none();
        let held = match user {
            Some(user) => store.roles_for(user.id).await?,
            None => Vec::new(),
        };

        let roles: Vec<Json> = store
            .roles()
            .await?
            .iter()
            .map(|role| {
                Json::object([
                    ("name", Json::from(role.name.as_str())),
                    (
                        "description",
                        role.description.clone().map_or(Json::Null, Json::from),
                    ),
                    ("assigned", Json::from(held.contains(&role.name))),
                ])
            })
            .collect();

        let mut permissions = Vec::new();
        if let Some(user) = user {
            let direct = store.direct_permissions(user.id).await?;
            let from_roles = store.permissions_for(user.id).await?;

            for permission in store.permissions().await? {
                let name = permission.name.clone();
                let explicit = direct.iter().find(|(p, _)| *p == name).map(|(_, granted)| *granted);
                let inherited = from_roles.contains(&name) && explicit.is_none();

                let choices = ["inherit", "grant", "deny"].map(|value| {
                    Json::object([
                        ("value", Json::from(value)),
                        (
                            "label",
                            Json::from(match value {
                                "grant" => "Allow",
                                "deny" => "Deny",
                                _ => "Inherit",
                            }),
                        ),
                        (
                            "selected",
                            Json::from(matches!(
                                (value, explicit),
                                ("grant", Some(true)) | ("deny", Some(false)) | ("inherit", None)
                            )),
                        ),
                    ])
                });

                permissions.push(Json::object([
                    ("name", Json::from(name)),
                    ("from_role", if inherited { Json::from("a role") } else { Json::Null }),
                    ("choices", Json::Array(choices.to_vec())),
                ]));
            }
        }

        Ok(context
            .with("is_new", Json::from(is_new))
            .with("title", Json::from(if is_new { "New user" } else { "Edit user" }))
            .with(
                "action",
                Json::from(match user {
                    Some(user) => format!("/admin/users/{}", user.id),
                    None => "/admin/users".to_string(),
                }),
            )
            .with("submit_label", Json::from(if is_new { "Send invitation" } else { "Save changes" }))
            .with("name", Json::from(user.map(|u| u.name.as_str()).unwrap_or_default()))
            .with("email", Json::from(user.map(|u| u.email.as_str()).unwrap_or_default()))
            .with("roles", Json::Array(roles))
            .with("permissions", Json::Array(permissions)))
    }
}


/// The RBAC store, or a clear failure. Never a silent `false`.
///
/// Cloned rather than borrowed: the store is a handle around shared state, and
/// a borrow of it would hold `req` immutably for the rest of the function —
/// which stops the same handler from reading its own form.
pub fn rbac(req: &Request) -> Result<Permissions> {
    req.state::<Permissions>().cloned().ok_or_else(|| {
        Error::msg(
            "the roles and permissions store is not registered. Add \
             `.plugin(Rbac::from_config(db.clone(), app.config()))` in main.rs.",
        )
    })
}

/// Fill in the signed-in user, which every admin page's chrome needs.
pub async fn with_current_user(
    context: ViewContext,
    req: &Request,
    db: &Database,
) -> Result<ViewContext> {
    let id = req.identity().and_then(|id| id.id_as::<i64>()).unwrap_or_default();
    match User::find(db, id).await? {
        Some(user) => page::with_user(context, req, &user).await,
        None => Ok(context),
    }
}

/// The `partials.pagination` variables.
pub fn pagination(context: ViewContext, req: &Request, page: i64, total: i64) -> ViewContext {
    let last = ((total + PER_PAGE - 1) / PER_PAGE).max(1);
    let path = req.path();
    let link = |n: i64| Json::from(format!("{path}?page={n}"));

    context
        .with("has_pages", Json::from(last > 1))
        .with("page_from", Json::from(((page - 1) * PER_PAGE + 1).min(total.max(1))))
        .with("page_to", Json::from((page * PER_PAGE).min(total)))
        .with("page_total", Json::from(total))
        .with("prev_url", if page > 1 { link(page - 1) } else { Json::Null })
        .with("next_url", if page < last { link(page + 1) } else { Json::Null })
}