permissions: homepage, sql + crate, add/remove/view for user

This commit is contained in:
Philippe Loctaux 2023-05-08 17:15:21 +02:00
parent 8dbeffddc9
commit 440f42ed2e
26 changed files with 880 additions and 1 deletions

View file

@ -32,3 +32,4 @@ refresh_tokens = { path = "../refresh_tokens" }
email = { path = "../email" }
totp = { path = "../totp" }
roles = { path = "../roles" }
permissions = { path = "../permissions" }

View file

@ -99,6 +99,12 @@ impl From<roles::Error> for Error {
}
}
impl From<permissions::Error> for Error {
fn from(e: permissions::Error) -> Self {
Error::internal_server_error(e)
}
}
// std Types
impl From<String> for Error {

View file

@ -43,13 +43,17 @@ impl Icon {
"paperclip", Paperclip, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-paperclip" 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="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"></path></svg>"#,
"paperclip-large", PaperclipLarge, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-lg icon-tabler icon-tabler-paperclip" 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="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"></path></svg>"#,
"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>"#,
"users-large", UsersLarge, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-lg 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>"#,
"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>"#,
"users-group", UsersGroup, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-users-group" 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 13a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M8 21v-1a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v1"></path><path d="M15 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M17 10h2a2 2 0 0 1 2 2v1"></path><path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M3 13v-1a2 2 0 0 1 2 -2h2"></path></svg>"#
"users-group", UsersGroup, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-users-group" 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 13a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M8 21v-1a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v1"></path><path d="M15 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M17 10h2a2 2 0 0 1 2 2v1"></path><path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M3 13v-1a2 2 0 0 1 2 -2h2"></path></svg>"#,
"users-group-large", UsersGroupLarge, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-lg icon-tabler icon-tabler-users-group" 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 13a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M8 21v-1a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v1"></path><path d="M15 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M17 10h2a2 2 0 0 1 2 2v1"></path><path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M3 13v-1a2 2 0 0 1 2 -2h2"></path></svg>"#,
"adjustments", Adjustments, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-adjustments" 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="M4 10a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M6 4v4"></path><path d="M6 12v8"></path><path d="M10 16a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M12 4v10"></path><path d="M12 18v2"></path><path d="M16 7a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M18 4v1"></path><path d="M18 9v11"></path></svg>"#,
"device-floppy", DeviceFloppy, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-device-floppy" 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="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"></path><path d="M12 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"></path><path d="M14 4l0 4l-6 0l0 -4"></path></svg>"#
}
}
@ -73,6 +77,7 @@ pub fn icons_to_templates(tera: &mut Tera) {
Icon::Paperclip,
Icon::PaperclipLarge,
Icon::Users,
Icon::UsersLarge,
Icon::Mail,
Icon::Password,
Icon::TwoFaLarge,
@ -80,6 +85,9 @@ pub fn icons_to_templates(tera: &mut Tera) {
Icon::X,
Icon::Progress,
Icon::UsersGroup,
Icon::UsersGroupLarge,
Icon::Adjustments,
Icon::DeviceFloppy,
];
// For each icon, it will output: ("icons/name", "<svg>...</svg>")

View file

@ -9,6 +9,7 @@ pub enum AdminMenu {
Apps,
Users,
Roles,
Permissions,
Settings,
}
@ -21,6 +22,7 @@ impl AdminMenu {
AdminMenu::Users => "users",
AdminMenu::Roles => "roles",
AdminMenu::Settings => "settings",
AdminMenu::Permissions => "permissions",
}
}
pub fn list() -> Vec<MainItem> {
@ -60,6 +62,13 @@ impl AdminMenu {
icon: Icon::UsersGroup.svg,
sub: None,
},
MainItem {
id: AdminMenu::Permissions.id(),
label: "Permissions",
link: uri!(routes::admin::permissions::admin_permissions_home).to_string(),
icon: Icon::Adjustments.svg,
sub: None,
},
MainItem {
id: AdminMenu::Settings.id(),
label: "Server settings",

View file

@ -35,6 +35,9 @@ pub enum Page {
AdminRolesList(AdminRolesList),
AdminRolesNew(AdminRolesNew),
AdminRolesView(AdminRolesView),
AdminPermissionsHome(AdminPermissionsHome),
AdminPermissionsUsers(AdminPermissionsUsers),
AdminPermissionsForUser(AdminPermissionsForUser),
}
impl Page {
@ -66,6 +69,9 @@ impl Page {
Page::AdminRolesList(_) => "pages/admin/roles/list",
Page::AdminRolesNew(_) => "pages/admin/roles/new",
Page::AdminRolesView(_) => "pages/admin/roles/view",
Page::AdminPermissionsHome(_) => "pages/admin/permissions/home",
Page::AdminPermissionsUsers(_) => "pages/admin/permissions/users",
Page::AdminPermissionsForUser(_) => "pages/admin/permissions/for-user",
}
}
@ -97,6 +103,9 @@ impl Page {
Page::AdminRolesList(_) => "Roles",
Page::AdminRolesNew(_) => "New role",
Page::AdminRolesView(_) => "Role info",
Page::AdminPermissionsHome(_) => "Permissions",
Page::AdminPermissionsUsers(_) => "Users permissions",
Page::AdminPermissionsForUser(_) => "Permissions for user",
}
}
@ -130,6 +139,9 @@ impl Page {
Page::AdminRolesList(_) => Some(AdminMenu::Roles.into()),
Page::AdminRolesNew(_) => Some(AdminMenu::Roles.into()),
Page::AdminRolesView(_) => Some(AdminMenu::Roles.into()),
Page::AdminPermissionsHome(_) => Some(AdminMenu::Permissions.into()),
Page::AdminPermissionsUsers(_) => Some(AdminMenu::Permissions.into()),
Page::AdminPermissionsForUser(_) => Some(AdminMenu::Permissions.into()),
}
}
@ -161,6 +173,9 @@ impl Page {
Page::AdminRolesList(list) => Box::new(list),
Page::AdminRolesNew(new) => Box::new(new),
Page::AdminRolesView(view) => Box::new(view),
Page::AdminPermissionsHome(home) => Box::new(home),
Page::AdminPermissionsUsers(users) => Box::new(users),
Page::AdminPermissionsForUser(user) => Box::new(user),
}
}
}

View file

@ -1,4 +1,5 @@
use self::apps::*;
use self::permissions::*;
use self::roles::*;
use self::settings::*;
use self::users::*;
@ -7,6 +8,7 @@ use rocket::{routes, Route};
pub mod apps;
pub mod dashboard;
pub mod permissions;
pub mod roles;
pub mod settings;
pub mod users;
@ -43,10 +45,15 @@ pub fn routes() -> Vec<Route> {
admin_roles_view,
admin_roles_archive,
admin_roles_info_update,
admin_permissions_home,
admin_permissions_users,
admin_permissions_for_user,
admin_permissions_for_user_form,
]
}
pub mod content {
use super::RolePermission;
use apps::App;
use jwt::JwtClaims;
use rocket::serde::Serialize;
@ -155,4 +162,28 @@ pub mod content {
pub role: Role,
pub jwt_duration: i64,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
#[derive(Clone)]
pub struct AdminPermissionsHome {
pub user: JwtClaims,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
#[derive(Clone)]
pub struct AdminPermissionsUsers {
pub user: JwtClaims,
pub users: Vec<User>,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
#[derive(Clone)]
pub struct AdminPermissionsForUser {
pub user: JwtClaims,
pub local: User,
pub roles_permissions: Vec<RolePermission>,
}
}

View file

@ -0,0 +1,193 @@
use crate::routes::prelude::*;
use crate::tokens::JWT_DURATION_MINUTES;
use chrono_humanize::Humanize;
use permissions::Permission;
use rocket::form::validate::Contains;
use rocket::serde::Serialize;
use rocket::{get, post};
use rocket_db_pools::sqlx::types::chrono::{DateTime, Utc};
use roles::Role;
use std::str::FromStr;
use users::User;
#[get("/admin/permissions")]
pub async fn admin_permissions_home(admin: JwtAdmin) -> Result<Page> {
Ok(Page::AdminPermissionsHome(
super::content::AdminPermissionsHome { user: admin.0 },
))
}
#[get("/admin/permissions/users")]
pub async fn admin_permissions_users(
mut db: Connection<Database>,
admin: JwtAdmin,
flash: Option<FlashMessage<'_>>,
) -> Result<Template> {
let mut transaction = db.begin().await?;
let users = User::get_all(&mut transaction)
.await?
.into_iter()
.filter(|user| !user.is_archived())
.collect();
transaction.commit().await?;
let page = Page::AdminPermissionsUsers(super::content::AdminPermissionsUsers {
user: admin.0,
users,
});
Ok(flash
.map(|flash| Page::with_flash(page.clone(), flash))
.unwrap_or_else(|| page.into()))
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
#[derive(Clone, Debug)]
pub struct PermissionTiming {
created_at: DateTime<Utc>,
relative: String,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
#[derive(Clone, Debug)]
pub struct RolePermission {
role: Role,
permission: Option<PermissionTiming>,
}
#[get("/admin/permissions/users/<id>")]
pub async fn admin_permissions_for_user(
mut db: Connection<Database>,
admin: JwtAdmin,
id: RocketUserID,
flash: Option<FlashMessage<'_>>,
) -> Result<Template> {
let mut transaction = db.begin().await?;
// Get user
let user = User::get_by_id(&mut transaction, &id.0)
.await?
.ok_or_else(|| Error::not_found("Failed to find user"))?;
if user.is_archived() {
return Err(Error::forbidden("User is archived"));
}
// Get roles
let roles = Role::get_all(&mut transaction)
.await?
.into_iter()
.filter(|role| !role.is_archived())
.collect::<Vec<_>>();
// Get permissions for user
let permissions = Permission::get_all(&mut transaction, Some(user.id()), None).await?;
transaction.commit().await?;
// For every role, attempt to get existing permission
let roles_permissions = roles
.iter()
.map(|role| RolePermission {
role: role.clone(),
permission: permissions
.iter()
.find(|perm| perm.role() == role.name())
.map(|perm| PermissionTiming {
created_at: perm.created_at(),
relative: perm.created_at().humanize(),
}),
})
.collect();
let page = Page::AdminPermissionsForUser(super::content::AdminPermissionsForUser {
user: admin.0,
local: user,
roles_permissions,
});
Ok(flash
.map(|flash| Page::with_flash(page.clone(), flash))
.unwrap_or_else(|| page.into()))
}
#[derive(Debug, FromForm)]
pub struct PermissionsForUserForm<'r> {
pub roles: Vec<&'r str>,
}
#[post("/admin/permissions/users/<id>", data = "<form>")]
pub async fn admin_permissions_for_user_form(
_admin: JwtAdmin,
mut db: Connection<Database>,
id: RocketUserID,
form: Form<PermissionsForUserForm<'_>>,
) -> Result<Flash<Redirect>> {
// Parse ids
let roles_to_use = form
.roles
.iter()
.map(|role| RoleID::from_str(role))
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|_| Error::bad_request(format!("Invalid role detected")))?;
let mut transaction = db.begin().await?;
// Get user
let user = User::get_by_id(&mut transaction, &id.0)
.await?
.ok_or_else(|| Error::not_found("Failed to find user"))?;
if user.is_archived() {
return Err(Error::forbidden("User is archived"));
}
// Get roles
let roles = Role::get_all(&mut transaction)
.await?
.into_iter()
.filter(|role| !role.is_archived())
.collect::<Vec<_>>();
// Get permissions for user
let permissions = Permission::get_all(&mut transaction, Some(user.id()), None).await?;
transaction.commit().await?;
let mut transaction = db.begin().await?;
// For every role, check to add or remove permissions
for role in &roles {
if roles_to_use.contains(role.name()) {
// Intent is to add new permission
if permissions.iter().all(|perm| perm.role() != role.name()) {
// If the permission does not exist, add it
Permission::add(&mut transaction, user.id(), role.name()).await?;
}
} else {
// Intent is to delete permission
if permissions.iter().any(|perm| perm.role() == role.name()) {
// If the permission exists, delete it
Permission::delete(&mut transaction, user.id(), role.name()).await?;
}
}
}
transaction.commit().await?;
Ok(Flash::new(
Redirect::to(uri!(admin_permissions_users)),
FlashKind::Success,
format!(
"Permissions have been updated for {}.\
<br>Changes can take up to {JWT_DURATION_MINUTES} minutes to appear.",
user.username()
),
))
}

View file

@ -0,0 +1,116 @@
{% extends "shell" %}
{% import "utils/form" as form %}
{% block content %}
<form id="permissions" action="" method="post">
{% set save = "Save" %}
<!-- Page header -->
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">
Roles for {{ local.username }}
</h2>
</div>
<div class="col-auto ms-auto">
<div class="btn-list">
<button type="submit" class="btn btn-primary d-none d-sm-inline-block">
{% include "icons/device-floppy" %}
{{ save }}
</button>
<button type="submit" class="btn btn-primary d-sm-none btn-icon" aria-label="{{ save }}">
{% include "icons/device-floppy" %}
</button>
</div>
</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 }}</h4>
</div>
{% endif %}
<div class="card">
<div class="card-header">
<h3 class="card-title">Roles</h3>
</div>
<div class="list-group list-group-flush overflow-auto">
{% for item in roles_permissions %}
{# If permission exists, show active class #}
{% if item.permission %}
<div class="list-group-item active">
{% else %}
<div class="list-group-item">
{% endif %}
<div class="row align-items-center">
<div class="col-auto">
<div class="col-auto">
{# If permission exists, tick the box #}
{% if item.permission %}
<input
name="roles"
value="{{ item.role.name }}"
id="select-{{ item.role.name }}"
type="checkbox"
class="form-check-input"
checked
>
{% else %}
<input
name="roles"
value="{{ item.role.name }}"
id="select-{{ item.role.name }}"
type="checkbox"
class="form-check-input"
>
{% endif %}
</div>
</div>
<div class="col text-truncate">
<!-- Role -->
<label for="select-{{ item.role.name }}" class="text-body">
{{ item.role.label }}
</label>
{# If permission exists, show how long the user belongs to the role #}
{% if item.permission %}
<div class="d-block text-muted text-truncate mt-1">
Since {{ item.permission.created_at | date(format="%F %T",
timezone=user.zoneinfo | default(value="UTC")) }}
<div class="mt-1">
({{ item.permission.relative }})
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</form>
{{ form::disable_button_delay_submit(form_id="permissions") }}
{% endblock content %}

View file

@ -0,0 +1,53 @@
{% extends "shell" %}
{% block content %}
<!-- Page header -->
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">
Permissions
</h2>
</div>
</div>
</div>
</div>
<!-- Page body -->
<div class="page-body">
<div class="container-xl">
<div class="row row-cards">
<!-- Users -->
<div class="col-sm-6">
<div class="card card-md">
<div class="card-body text-center">
<div class="text-uppercase font-weight-medium">Users</div>
<div class="display-5 fw-bold my-3">
{% include "icons/users-large" %}
</div>
<div class="text-center mt-4">
<a href="permissions/users" class="btn btn-primary w-100">Permissions for users</a>
</div>
</div>
</div>
</div>
<!-- Roles -->
<div class="col-sm-6">
<div class="card card-md">
<div class="card-body text-center">
<div class="text-uppercase font-weight-medium">Roles</div>
<div class="display-5 fw-bold my-3">
{% include "icons/users-group-large" %}
</div>
<div class="text-center mt-4">
<a href="permissions/roles" class="btn btn-primary w-100">Permissions for roles</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,58 @@
{% extends "shell" %}
{% block content %}
<!-- Page header -->
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">
Permissions for 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 %}
<div class="card">
<div class="card-header">
<h3 class="card-title">Users</h3>
</div>
<div class="list-group list-group-flush overflow-auto">
{% for user_loop in users %}
<div class="list-group-item">
<div class="row align-items-center">
<div class="col-auto">
{{ user::avatar(username=user_loop.username, name=user_loop.name, size="sm", css="") }}
</div>
<div class="col text-truncate">
<div class="text-body">
{% if user_loop.name %}
{{ user_loop.name }}
{% else %}
{{ user_loop.username }}
{% endif %}
</div>
</div>
<div class="col-auto">
<a href="users/{{ user_loop.id }}" class="list-group-item-actions btn btn-outline-primary">Select</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock content %}