admin/roles: view, archive and restore

This commit is contained in:
Philippe Loctaux 2023-05-07 18:39:02 +02:00
parent 8fa2fb7ddc
commit d778380d8b
10 changed files with 363 additions and 2 deletions

View file

@ -0,0 +1,7 @@
select name,
label,
created_at as "created_at: DateTime<Utc>",
is_archived as "is_archived: bool"
from roles
where name is (?)

View file

@ -0,0 +1,5 @@
update roles
set is_archived = ?
where name is ?

View file

@ -110,6 +110,16 @@
}, },
"query": "update apps\n\nset is_archived = 1\n\nwhere id is ?" "query": "update apps\n\nset is_archived = 1\n\nwhere id is ?"
}, },
"2ee77458d93ae79d957e03e6ca50c1bf690f40b6cc6ec5f008eb0e928f376659": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 2
}
},
"query": "update roles\n\nset is_archived = ?\n\nwhere name is ?"
},
"32d35bdd1f4cf64ce0ff7beb7a11591e0f35eab7211692bcde8230c68e4cedf3": { "32d35bdd1f4cf64ce0ff7beb7a11591e0f35eab7211692bcde8230c68e4cedf3": {
"describe": { "describe": {
"columns": [], "columns": [],
@ -864,6 +874,42 @@
}, },
"query": "select id,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n is_admin as \"is_admin: bool\",\n username,\n name,\n email,\n password,\n password_recover,\n paper_key,\n is_archived as \"is_archived: bool\",\n timezone,\n totp_secret,\n totp_backup\nfrom users\n\norder by created_at desc\n" "query": "select id,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n is_admin as \"is_admin: bool\",\n username,\n name,\n email,\n password,\n password_recover,\n paper_key,\n is_archived as \"is_archived: bool\",\n timezone,\n totp_secret,\n totp_backup\nfrom users\n\norder by created_at desc\n"
}, },
"9a3aef02e55fc436e9c09c1ee6d29477c2746765f6e7d5679058ec6525ee7253": {
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "label",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "is_archived: bool",
"ordinal": 3,
"type_info": "Int64"
}
],
"nullable": [
false,
false,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "select name,\n label,\n created_at as \"created_at: DateTime<Utc>\",\n is_archived as \"is_archived: bool\"\nfrom roles\n\nwhere name is (?)\n"
},
"9dc379b0f3a3c944a33ef01dc40489b29b2a61b9f73f6ca4b5df7e9fb9bccf90": { "9dc379b0f3a3c944a33ef01dc40489b29b2a61b9f73f6ca4b5df7e9fb9bccf90": {
"describe": { "describe": {
"columns": [ "columns": [

View file

@ -31,4 +31,28 @@ impl Roles {
.await .await
.map_err(handle_error) .map_err(handle_error)
} }
pub async fn get_one_by_name(
conn: impl SqliteExecutor<'_>,
name: &str,
) -> Result<Option<Self>, Error> {
sqlx::query_file_as!(Self, "queries/roles/get_one_by_name.sql", name)
.fetch_optional(conn)
.await
.map_err(handle_error)
}
pub async fn set_archive_status(
conn: impl SqliteExecutor<'_>,
id: &str,
value: bool,
) -> Result<Option<()>, Error> {
let query: SqliteQueryResult =
sqlx::query_file!("queries/roles/set_archive_status.sql", value, id)
.execute(conn)
.await
.map_err(handle_error)?;
Ok((query.rows_affected() == 1).then_some(()))
}
} }

View file

@ -34,6 +34,7 @@ pub enum Page {
AdminUsersNew(AdminUsersNew), AdminUsersNew(AdminUsersNew),
AdminRolesList(AdminRolesList), AdminRolesList(AdminRolesList),
AdminRolesNew(AdminRolesNew), AdminRolesNew(AdminRolesNew),
AdminRolesView(AdminRolesView),
} }
impl Page { impl Page {
@ -64,6 +65,7 @@ impl Page {
Page::AdminUsersNew(_) => "pages/admin/users/new", Page::AdminUsersNew(_) => "pages/admin/users/new",
Page::AdminRolesList(_) => "pages/admin/roles/list", Page::AdminRolesList(_) => "pages/admin/roles/list",
Page::AdminRolesNew(_) => "pages/admin/roles/new", Page::AdminRolesNew(_) => "pages/admin/roles/new",
Page::AdminRolesView(_) => "pages/admin/roles/view",
} }
} }
@ -94,6 +96,7 @@ impl Page {
Page::AdminUsersNew(_) => "New user", Page::AdminUsersNew(_) => "New user",
Page::AdminRolesList(_) => "Roles", Page::AdminRolesList(_) => "Roles",
Page::AdminRolesNew(_) => "New role", Page::AdminRolesNew(_) => "New role",
Page::AdminRolesView(_) => "Role info",
} }
} }
@ -126,6 +129,7 @@ impl Page {
Page::AdminUsersNew(_) => Some(AdminMenu::Users.into()), Page::AdminUsersNew(_) => Some(AdminMenu::Users.into()),
Page::AdminRolesList(_) => Some(AdminMenu::Roles.into()), Page::AdminRolesList(_) => Some(AdminMenu::Roles.into()),
Page::AdminRolesNew(_) => Some(AdminMenu::Roles.into()), Page::AdminRolesNew(_) => Some(AdminMenu::Roles.into()),
Page::AdminRolesView(_) => Some(AdminMenu::Roles.into()),
} }
} }
@ -156,6 +160,7 @@ impl Page {
Page::AdminUsersNew(new) => Box::new(new), Page::AdminUsersNew(new) => Box::new(new),
Page::AdminRolesList(list) => Box::new(list), Page::AdminRolesList(list) => Box::new(list),
Page::AdminRolesNew(new) => Box::new(new), Page::AdminRolesNew(new) => Box::new(new),
Page::AdminRolesView(view) => Box::new(view),
} }
} }
} }

View file

@ -40,6 +40,8 @@ pub fn routes() -> Vec<Route> {
admin_roles_list, admin_roles_list,
admin_roles_new, admin_roles_new,
admin_roles_new_form, admin_roles_new_form,
admin_roles_view,
admin_roles_archive,
] ]
} }
@ -143,4 +145,13 @@ pub mod content {
pub struct AdminRolesNew { pub struct AdminRolesNew {
pub user: JwtClaims, pub user: JwtClaims,
} }
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
#[derive(Clone)]
pub struct AdminRolesView {
pub user: JwtClaims,
pub role: Role,
pub jwt_duration: i64,
}
} }

View file

@ -1,4 +1,5 @@
use crate::routes::prelude::*; use crate::routes::prelude::*;
use crate::tokens::JWT_DURATION_MINUTES;
use rocket::{get, post}; use rocket::{get, post};
use roles::Role; use roles::Role;
use std::str::FromStr; use std::str::FromStr;
@ -71,9 +72,107 @@ pub async fn admin_roles_new_form(
transaction.commit().await?; transaction.commit().await?;
let id = RocketRoleID(name);
Ok(Flash::new( Ok(Flash::new(
Redirect::to(uri!(admin_roles_list)), Redirect::to(uri!(admin_roles_view(id))),
FlashKind::Success, FlashKind::Success,
"Role has been created.", "Role has been created.",
)) ))
} }
#[get("/admin/roles/<id>")]
pub async fn admin_roles_view(
admin: JwtAdmin,
mut db: Connection<Database>,
id: RocketRoleID,
flash: Option<FlashMessage<'_>>,
) -> Result<Template> {
let role_id = id.0;
let mut transaction = db.begin().await?;
let role = Role::get_by_name(&mut transaction, &role_id)
.await?
.ok_or_else(|| Error::not_found(role_id.to_string()))?;
transaction.commit().await?;
let page = Page::AdminRolesView(super::content::AdminRolesView {
user: admin.0,
role,
jwt_duration: JWT_DURATION_MINUTES,
});
Ok(flash
.map(|flash| Page::with_flash(page.clone(), flash))
.unwrap_or_else(|| page.into()))
}
#[derive(Debug, FromForm)]
pub struct ArchiveRoleForm {
pub archive: Option<bool>,
pub restore: Option<bool>,
}
#[post("/admin/roles/<id>/archive", data = "<form>")]
pub async fn admin_roles_archive(
_admin: JwtAdmin,
mut db: Connection<Database>,
id: RocketRoleID,
form: Form<ArchiveRoleForm>,
) -> Result<Flash<Redirect>> {
let (redirect, flash_kind, flash_message) = match (form.archive, form.restore) {
(Some(true), _) => {
// Archive role
let mut transaction = db.begin().await?;
// Get role
let role = Role::get_by_name(&mut transaction, &id.0)
.await?
.ok_or_else(|| Error::not_found("Could not find role"))?;
// Set new status
role.set_archive_status(&mut transaction, true).await?;
transaction.commit().await?;
(
Redirect::to(uri!(admin_roles_list)),
FlashKind::Success,
"Role has been archived.",
)
}
(_, Some(true)) => {
// Restore role
let mut transaction = db.begin().await?;
// Get role
let role = Role::get_by_name(&mut transaction, &id.0)
.await?
.ok_or_else(|| Error::not_found("Could not find role"))?;
// Set new status
role.set_archive_status(&mut transaction, false).await?;
transaction.commit().await?;
(
Redirect::to(uri!(admin_roles_view(id))),
FlashKind::Success,
"Role has been restored.",
)
}
_ => {
// Nothing to do
(
Redirect::to(uri!(admin_roles_view(id))),
FlashKind::Warning,
"Nothing to do.",
)
}
};
Ok(Flash::new(redirect, flash_kind, flash_message))
}

View file

@ -40,7 +40,7 @@
class="form-control" class="form-control"
required> required>
<small class="form-hint"> <small class="form-hint">
This name identifies the role inside the system. It is not public.<br> This name is for internal usage. It is not public and cannot be changed.<br>
Allowed characters: <code>[a-z][0-9]-</code> Allowed characters: <code>[a-z][0-9]-</code>
</small> </small>
</div> </div>

View file

@ -0,0 +1,145 @@
{% extends "shell" %}
{% block content %}
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">
Admin dashboard
</div>
<h2 class="page-title">
Roles
</h2>
</div>
</div>
</div>
</div>
<!-- Page body -->
<div class="page-body">
<div class="container-xl">
{% if flash %}
<div class="alert alert-{{flash.0}}" role="alert">
<h4 class="alert-title">{{ flash.1 | safe }}</h4>
</div>
{% endif %}
{% if role.is_archived %}
<div class="card">
<div class="card-header">
<h3 class="card-title">Archived role</h3>
</div>
<div class="card-body">
<p>This role is archived.</p>
<form action="{{ role.name }}/archive" method="post">
<button type="submit" name="restore" value="true"
class="btn btn-primary">
Restore role
</button>
</form>
</div>
</div>
{% else %}
<div class="card">
<div class="card-header">
<h3 class="card-title">Role information</h3>
</div>
<form action="{{ role.name }}/info" method="post">
<div class="card-body">
<!-- Name -->
<div class="mb-3">
<label class="form-label" for="name">Name</label>
<div>
<input id="name" value="{{ role.name }}" type="text"
class="form-control cursor-not-allowed" readonly>
</div>
</div>
<!-- Label -->
<div class="mb-3">
<label class="form-label required" for="label">Label</label>
<div>
<input name="label" value="{{ role.label }}" id="label" type="text"
placeholder="Enter label"
class="form-control"
required>
<small class="form-hint">
The label is shown to the users.
</small>
</div>
</div>
</div>
<div class="card-footer text-end">
<div class="d-flex">
<a class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#modal-archive">
Archive
</a>
<button type="submit" class="btn btn-primary ms-auto">Save</button>
</div>
</div>
</form>
</div>
{% endif %}
</div>
</div>
<!-- Archive modal -->
<div class="modal modal-blur" tabindex="-1" id="modal-archive">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-status bg-danger"></div>
<div class="modal-body text-center py-4">
<div class="text-danger mb-2">
{% include "icons/alert-triangle-large" %}
</div>
<h3>Do you want to archive this role?</h3>
<div class="mt-2">This role will not be able to be used by users.</div>
<div class="mt-2">
This action can take up to {{ jwt_duration }} minutes to be effective for all users.
</div>
</div>
<div class="modal-footer">
<div class="w-100">
<div class="row">
<div class="col">
<a href="#" class="btn w-100" data-bs-dismiss="modal">Cancel</a>
</div>
<div class="col">
<form action="{{ role.name }}/archive" method="post">
<button type="submit" name="archive" value="true"
class="btn btn-danger w-100">
Archive role
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block libs_js %}
{% endblock lib_js %}
{% block additional_js %}
{% endblock additional_js %}

View file

@ -41,4 +41,23 @@ impl Role {
Ok(()) Ok(())
} }
pub async fn get_by_name(
conn: impl SqliteExecutor<'_>,
name: &RoleID,
) -> Result<Option<Self>, Error> {
Ok(DatabaseRoles::get_one_by_name(conn, name.as_ref())
.await?
.map(Self::from))
}
pub async fn set_archive_status(
&self,
conn: impl SqliteExecutor<'_>,
value: bool,
) -> Result<(), Error> {
DatabaseRoles::set_archive_status(conn, self.name.as_ref(), value).await?;
Ok(())
}
} }