admin/users: view user, archive + restore

This commit is contained in:
Philippe Loctaux 2023-05-03 21:49:25 +02:00
parent 4a63bfa9a9
commit e600405f22
6 changed files with 492 additions and 4 deletions

View file

@ -45,7 +45,10 @@ impl Icon {
"users", Users, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-users" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"></path><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path><path d="M21 21v-2a4 4 0 0 0 -3 -3.85"></path></svg>"#,
"mail", Mail, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-mail" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10z"></path><path d="M3 7l9 6l9 -6"></path></svg>"#,
"password", Password, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-password" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M12 10v4"></path><path d="M10 13l4 -2"></path><path d="M10 11l4 2"></path><path d="M5 10v4"></path><path d="M3 13l4 -2"></path><path d="M3 11l4 2"></path><path d="M19 10v4"></path><path d="M17 13l4 -2"></path><path d="M17 11l4 2"></path></svg>"#,
"2fa-large", TwoFaLarge, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-lg icon-tabler icon-tabler-2fa" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M7 16h-4l3.47 -4.66a2 2 0 1 0 -3.47 -1.54"></path><path d="M10 16v-8h4"></path><path d="M10 12l3 0"></path><path d="M17 16v-6a2 2 0 0 1 4 0v6"></path><path d="M17 13l4 0"></path></svg>"#
"2fa-large", TwoFaLarge, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-lg icon-tabler icon-tabler-2fa" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M7 16h-4l3.47 -4.66a2 2 0 1 0 -3.47 -1.54"></path><path d="M10 16v-8h4"></path><path d="M10 12l3 0"></path><path d="M17 16v-6a2 2 0 0 1 4 0v6"></path><path d="M17 13l4 0"></path></svg>"#,
"check", Check, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-check" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M5 12l5 5l10 -10"></path></svg>"#,
"x", X, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-x" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M18 6l-12 12"></path><path d="M6 6l12 12"></path></svg>"#,
"progress", Progress, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-progress" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M10 20.777a8.942 8.942 0 0 1 -2.48 -.969"></path><path d="M14 3.223a9.003 9.003 0 0 1 0 17.554"></path><path d="M4.579 17.093a8.961 8.961 0 0 1 -1.227 -2.592"></path><path d="M3.124 10.5c.16 -.95 .468 -1.85 .9 -2.675l.169 -.305"></path><path d="M6.907 4.579a8.954 8.954 0 0 1 3.093 -1.356"></path></svg>"#
}
}
@ -72,6 +75,9 @@ pub fn icons_to_templates(tera: &mut Tera) {
Icon::Mail,
Icon::Password,
Icon::TwoFaLarge,
Icon::Check,
Icon::X,
Icon::Progress,
];
// For each icon, it will output: ("icons/name", "<svg>...</svg>")

View file

@ -6,7 +6,7 @@ use rocket::serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct RocketUserID(pub UserID);
@ -18,7 +18,15 @@ impl<'r> FromParam<'r> for RocketUserID {
}
}
#[derive(Serialize, Deserialize)]
impl UriDisplay<Path> for RocketUserID {
fn fmt(&self, f: &mut Formatter<Path>) -> fmt::Result {
UriDisplay::fmt(&self.0 .0, f)
}
}
impl_from_uri_param_identity!([Path] RocketUserID);
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct RocketAppID(pub AppID);

View file

@ -29,6 +29,7 @@ pub enum Page {
ResetPassword(ResetPassword),
UserSecurityTotp(UserSecurityTotp),
AuthorizeTotp(AuthorizeTotp),
AdminUsersView(AdminUsersView),
}
impl Page {
@ -54,6 +55,7 @@ impl Page {
Page::ResetPassword(_) => "pages/reset-password",
Page::UserSecurityTotp(_) => "pages/settings/totp",
Page::AuthorizeTotp(_) => "pages/oauth/totp",
Page::AdminUsersView(_) => "pages/admin/users/view",
}
}
@ -79,6 +81,7 @@ impl Page {
Page::ResetPassword(_) => "Reset password",
Page::UserSecurityTotp(_) => "Enable One-time password",
Page::AuthorizeTotp(_) => "Verifying your account",
Page::AdminUsersView(_) => "User info",
}
}
@ -106,6 +109,7 @@ impl Page {
Page::ResetPassword(_) => None,
Page::UserSecurityTotp(_) => Some(UserMenu::Settings.into()),
Page::AuthorizeTotp(_) => None,
Page::AdminUsersView(_) => Some(AdminMenu::Users.into()),
}
}
@ -131,6 +135,7 @@ impl Page {
Page::ResetPassword(reset) => Box::new(reset),
Page::UserSecurityTotp(totp) => Box::new(totp),
Page::AuthorizeTotp(totp) => Box::new(totp),
Page::AdminUsersView(view) => Box::new(view),
}
}
}

View file

@ -24,6 +24,8 @@ pub fn routes() -> Vec<Route> {
admin_apps_new_secret,
admin_apps_archive,
admin_users_list,
admin_users_view,
admin_users_archive,
]
}
@ -86,4 +88,13 @@ pub mod content {
pub user: JwtClaims,
pub users: Vec<User>,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
#[derive(Clone)]
pub struct AdminUsersView {
pub user: JwtClaims,
pub jwt_duration: i64,
pub local: User,
}
}

View file

@ -1,5 +1,9 @@
use crate::routes::prelude::*;
use rocket::get;
use crate::tokens::JWT_DURATION_MINUTES;
use authorization_codes::AuthorizationCode;
use rocket::{get, post};
use settings::Settings;
use users::totp_login_request::TotpLoginRequest;
use users::User;
#[get("/admin/users")]
@ -19,3 +23,116 @@ pub async fn admin_users_list(
.map(|flash| Page::with_flash(page.clone(), flash))
.unwrap_or_else(|| page.into()))
}
#[get("/admin/users/<id>")]
pub async fn admin_users_view(
admin_not_current: JwtAdminNotCurrent,
mut db: Connection<Database>,
id: RocketUserID,
flash: Option<FlashMessage<'_>>,
) -> Result<Template> {
let user_id = id.0;
let user = User::get_by_id(&mut *db, &user_id)
.await?
.ok_or_else(|| Error::not_found(user_id.to_string()))?;
let page = Page::AdminUsersView(super::content::AdminUsersView {
user: admin_not_current.0,
jwt_duration: JWT_DURATION_MINUTES,
local: user,
});
Ok(flash
.map(|flash| Page::with_flash(page.clone(), flash))
.unwrap_or_else(|| page.into()))
}
#[derive(Debug, FromForm)]
pub struct ArchiveUserForm {
pub archive: Option<bool>,
pub restore: Option<bool>,
}
#[post("/admin/users/<id>/archive", data = "<form>")]
pub async fn admin_users_archive(
_admin_not_current: JwtAdminNotCurrent,
mut db: Connection<Database>,
id: RocketUserID,
form: Form<ArchiveUserForm>,
) -> Result<Flash<Redirect>> {
let (redirect, flash_kind, flash_message) = match (form.archive, form.restore) {
(Some(true), _) => {
// Archive user
let mut transaction = db.begin().await?;
// Get ID of first admin
let settings = Settings::get(&mut transaction).await?;
let first_admin = settings
.first_admin()
.ok_or_else(|| Error::bad_request("First user is not set"))?;
// Get user
let user = User::get_by_id(&mut transaction, &id.0)
.await?
.ok_or_else(|| Error::not_found("Could not find user"))?;
// If attempting to archive first admin user
if user.id().as_ref() == first_admin {
return Err(Error::bad_request("Can't archive first admin user"));
}
// Set new status
user.set_archive_status(&mut transaction, true).await?;
// Revoke refresh tokens
refresh_tokens::RefreshToken::revoke_all_for_user(&mut transaction, user.id()).await?;
// Use all authorization codes
AuthorizationCode::use_all_for_user(&mut transaction, user.id()).await?;
// Use all totp login requests
if user.totp_secret().is_some() {
TotpLoginRequest::use_all_for_user(&mut transaction, user.id()).await?;
}
transaction.commit().await?;
(
Redirect::to(uri!(admin_users_list)),
FlashKind::Success,
"User has been archived.",
)
}
(_, Some(true)) => {
// Restore user
let mut transaction = db.begin().await?;
let user = User::get_by_id(&mut transaction, &id.0)
.await?
.ok_or_else(|| Error::not_found("Could not find user"))?;
// Set new status
user.set_archive_status(&mut transaction, false).await?;
transaction.commit().await?;
(
Redirect::to(uri!(admin_users_view(id))),
FlashKind::Success,
"User has been restored.",
)
}
_ => {
// Nothing to do
(
Redirect::to(uri!(admin_users_view(id))),
FlashKind::Warning,
"Nothing to do.",
)
}
};
Ok(Flash::new(redirect, flash_kind, flash_message))
}

View file

@ -0,0 +1,341 @@
{% 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">
Users
</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 local.is_archived %}
<div class="card">
<div class="card-header">
<h3 class="card-title">Archived user</h3>
</div>
<div class="card-body">
<p>This user is archived.</p>
<form action="{{ local.id }}/archive" method="post">
<button type="submit" name="restore" value="true"
class="btn btn-primary">
Restore user
</button>
</form>
</div>
</div>
{% else %}
<div class="card">
<div class="card-header">
<h3 class="card-title">User information</h3>
</div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title">
<label class="required" for="username">Username</label>
</div>
<div class="datagrid-content">
<div class="input-icon">
<span class="input-icon-addon">
{% include "icons/id-badge-2" %}
</span>
<input name="username" id="username" value="{{ local.username }}" type="text"
placeholder="Enter a username"
class="form-control"
required>
</div>
</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">
<label for="name">Full Name</label>
</div>
<div class="datagrid-content">
<div class="input-icon">
<span class="input-icon-addon">
{% include "icons/user" %}
</span>
<input name="name" id="name" value="{{ local.name }}" type="text"
placeholder="Napoleon Bonaparte"
class="form-control">
</div>
</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">
<label for="email">Email address</label>
</div>
<div class="datagrid-content">
<div class="input-icon">
<span class="input-icon-addon">
{% include "icons/at" %}
</span>
<input name="email" id="email" value="{{ local.email }}" type="email"
placeholder="napoleon@bonaparte.fr"
class="form-control">
</div>
</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Admin status</div>
<div class="datagrid-content">
<div class="mt-2">
<label class="form-check">
{% if local.is_admin %}
<input class="form-check-input" type="checkbox" checked>
{% else %}
<input class="form-check-input" type="checkbox">
{% endif %}
<span class="form-check-label">Administrator</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="card-footer text-end">
<div class="d-flex">
<button type="submit" class="btn btn-primary ms-auto">Save</button>
</div>
</div>
</div>
<div class="mt-4 card">
<div class="card-header">
<h3 class="card-title">Security</h3>
</div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title">Password</div>
<div class="datagrid-content">
<div>
{% if local.password %}
<span class="text-green">{% include "icons/check" %}</span>
Password is set
{% else %}
<span class="text-red">{% include "icons/x" %}</span>
Password is not set
{% endif %}
</div>
{% if local.password %}
<div class="mt-1">
{% if local.paper_key %}
<span class="text-green">{% include "icons/check" %}</span>
Paper key is set
{% else %}
<span class="text-red">{% include "icons/x" %}</span>
Paper key is not set
{% endif %}
</div>
{% endif %}
{% if local.password_recover %}
<div class="mt-1">
<span class="text-yellow">{% include "icons/progress" %}</span>
Password reset has been requested
</div>
{% endif %}
</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">2FA (TOTP)</div>
<div class="datagrid-content">
<div>
{% if local.totp_secret %}
<span class="text-green">{% include "icons/check" %}</span>
TOTP is enabled
{% else %}
<span class="text-red">{% include "icons/x" %}</span>
TOTP is not enabled
{% endif %}
</div>
{% if local.totp_secret %}
<div class="mt-1">
{% if local.totp_backup %}
<span class="text-green">{% include "icons/check" %}</span>
TOTP backup is enabled
{% else %}
<span class="text-red">{% include "icons/x" %}</span>
TOTP backup is not enabled
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="mt-4 card">
<div class="card-header">
<h3 class="card-title">Timings</h3>
</div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title">Account Creation</div>
<div class="datagrid-content">
{{ local.created_at | date(format="%F %T", timezone=user.zoneinfo | default(value="UTC")) }}
</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Account Modification</div>
<div class="datagrid-content">
{{ local.updated_at | date(format="%F %T", timezone=user.zoneinfo | default(value="UTC")) }}
</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Account Timezone</div>
<div class="datagrid-content">{{ local.timezone }}</div>
</div>
</div>
</div>
</div>
<div class="mt-4 card">
<div class="card-header bg-danger-lt">
<h3 class="card-title">Danger zone</h3>
</div>
<div class="card-body">
<!-- Archive user -->
<div class="mb-4">
<a class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#modal-archive">
Archive user
</a>
</div>
{% if local.password %}
<h2 class="mb-4">Password</h2>
<!-- Reset password -->
<div class="mb-4">
<a class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#modal-password-reset">
Force password reset
</a>
</div>
<!-- Reset paper key -->
{% if local.paper_key %}
<div class="mb-4">
<a class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#modal-paper-key">
Reset paper key
</a>
</div>
{% endif %}
{% endif %}
{% if local.totp_secret %}
<h2 class="mb-4">TOTP</h2>
<!-- Disable TOTP -->
<div class="mb-4">
<a class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#modal-totp-secret">
Disable TOTP
</a>
</div>
{% if local.totp_backup %}
<!-- Delete TOTP backup -->
<div class="mb-4">
<a class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#modal-totp-backup">
Delete TOTP backup
</a>
</div>
{% endif %}
{% endif %}
</div>
</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 user?</h3>
<div class="mt-2">This user will not be able to log in.</div>
<div class="mt-2">This action can take up to {{ jwt_duration }} minutes to be effective.</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="{{ local.id }}/archive" method="post">
<button type="submit" name="archive" value="true"
class="btn btn-danger w-100">
Archive user
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block libs_js %}
{% endblock lib_js %}
{% block additional_js %}
{% endblock additional_js %}