Add admin panel with basic user management operations
This commit is contained in:
parent
46058b2943
commit
e322da0185
46
.sqlx/query-905f7524977b1cb7b8e78f4671ae59f21a82a9a2944a98712dd18a1aea9d9c8d.json
generated
Normal file
46
.sqlx/query-905f7524977b1cb7b8e78f4671ae59f21a82a9a2944a98712dd18a1aea9d9c8d.json
generated
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT user_id, user_name, password_hash, locked, created_at_utc FROM users WHERE user_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "password_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "locked",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at_utc",
|
||||
"type_info": "Timestamp"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "905f7524977b1cb7b8e78f4671ae59f21a82a9a2944a98712dd18a1aea9d9c8d"
|
||||
}
|
23
.sqlx/query-90b1d6e644b5e5d3b19bf96f3b3d8b93948d3e9311f50a5da3c5f8b23d8998b8.json
generated
Normal file
23
.sqlx/query-90b1d6e644b5e5d3b19bf96f3b3d8b93948d3e9311f50a5da3c5f8b23d8998b8.json
generated
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(1) AS \"count!\" FROM users_roles WHERE user_id = $1 AND role_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "90b1d6e644b5e5d3b19bf96f3b3d8b93948d3e9311f50a5da3c5f8b23d8998b8"
|
||||
}
|
27
src/store.rs
27
src/store.rs
@ -4,6 +4,7 @@
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use bevy_reflect::Reflect;
|
||||
use chrono::DateTime;
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::Utc;
|
||||
@ -139,6 +140,7 @@ pub struct UserInfo {
|
||||
}
|
||||
|
||||
/// Basic information about a role
|
||||
#[derive(Reflect)]
|
||||
pub struct RoleInfo {
|
||||
/// The ID of the role.
|
||||
/// Is alphanumeric.
|
||||
@ -292,6 +294,16 @@ impl IdCoopStoreTxn<'_, '_> {
|
||||
.fetch_optional(&mut **self.txn)
|
||||
.await.context("failed to lookup user from DB")
|
||||
}
|
||||
/// Given a user's ID, return their user record if they exist.
|
||||
pub async fn lookup_user_by_id(&mut self, id: Uuid) -> eyre::Result<Option<User>> {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
"SELECT user_id, user_name, password_hash, locked, created_at_utc FROM users WHERE user_id = $1",
|
||||
id
|
||||
)
|
||||
.fetch_optional(&mut **self.txn)
|
||||
.await.context("failed to lookup user from DB")
|
||||
}
|
||||
|
||||
/// Set a given user's password.
|
||||
pub async fn change_user_password(
|
||||
@ -540,6 +552,7 @@ impl IdCoopStoreTxn<'_, '_> {
|
||||
.context("failed to add user to role")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a user to a role.
|
||||
pub async fn remove_user_from_role(
|
||||
&mut self,
|
||||
@ -556,4 +569,18 @@ impl IdCoopStoreTxn<'_, '_> {
|
||||
.context("failed to remove user from role")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests whether a user is in the given role.
|
||||
pub async fn is_user_in_role(&mut self, user_id: Uuid, role_id: &str) -> eyre::Result<bool> {
|
||||
let in_role = sqlx::query_scalar!(
|
||||
"SELECT COUNT(1) AS \"count!\" FROM users_roles WHERE user_id = $1 AND role_id = $2",
|
||||
user_id,
|
||||
role_id
|
||||
)
|
||||
.fetch_one(&mut **self.txn)
|
||||
.await
|
||||
.context("failed to query role membership")?
|
||||
== 1;
|
||||
Ok(in_role)
|
||||
}
|
||||
}
|
||||
|
@ -51,8 +51,12 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
use self::logout::{get_logout, post_logout};
|
||||
use self::{
|
||||
admin_panel::admin_router,
|
||||
logout::{get_logout, post_logout},
|
||||
};
|
||||
|
||||
pub mod admin_panel;
|
||||
pub(crate) mod ambient;
|
||||
pub mod errors;
|
||||
pub mod login;
|
||||
@ -197,6 +201,7 @@ pub(crate) async fn make_router(
|
||||
get(oidc_discovery_configuration)
|
||||
.layer(CorsLayer::permissive().max_age(Duration::from_secs(600))),
|
||||
)
|
||||
.nest("/admin", admin_router())
|
||||
.nest_service(
|
||||
"/static",
|
||||
get_service(ServeDir::new(find_static_directory_to_serve()).fallback(
|
||||
|
516
src/web/admin_panel.rs
Normal file
516
src/web/admin_panel.rs
Normal file
@ -0,0 +1,516 @@
|
||||
//! The Admin Panel allows idCoop administrators to perform administrative actions
|
||||
//! in a simple web user interface.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{OriginalUri, Path, Request},
|
||||
http::StatusCode,
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Extension, Form, Router,
|
||||
};
|
||||
use bevy_reflect::Reflect;
|
||||
use eyre::{eyre, Context};
|
||||
use formbeam::{traits::FormValidation, FormPartial};
|
||||
use hornbeam::{render_template_string, ReflectedForm};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
config::Configuration,
|
||||
passwords::create_password_hash,
|
||||
store::{IdCoopStore, RoleInfo, User, UserInfo},
|
||||
utils::Clock,
|
||||
web::make_login_redirect,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ambient::Ambient, errors, login::LoginSession, DesiredLocale, Rendered, WebResult, TEMPLATING,
|
||||
};
|
||||
|
||||
const ADMIN_ROLE: &str = "idcoop/admin";
|
||||
|
||||
pub(crate) fn admin_router() -> Router<()> {
|
||||
Router::new()
|
||||
.route("/", get(get_admin_users))
|
||||
.route("/users", get(get_admin_users))
|
||||
.route(
|
||||
"/users/{user_id}",
|
||||
get(admin_get_user).post(admin_post_user),
|
||||
)
|
||||
.route(
|
||||
"/users/{user_id}/set_password",
|
||||
get(admin_get_set_password).post(admin_post_set_password),
|
||||
)
|
||||
.route(
|
||||
"/users/{user_id}/add_roles",
|
||||
get(admin_get_add_roles).post(admin_post_add_roles),
|
||||
)
|
||||
.layer(middleware::from_fn(admin_auth_layer))
|
||||
}
|
||||
|
||||
async fn admin_auth_layer(
|
||||
session: Option<LoginSession>,
|
||||
Extension(store): Extension<Arc<IdCoopStore>>,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> WebResult<Response> {
|
||||
let Some(session) = session else {
|
||||
return Ok(make_login_redirect(uri));
|
||||
};
|
||||
|
||||
if !store
|
||||
.txn(|mut txn| {
|
||||
Box::pin(async move { txn.is_user_in_role(session.user_id, ADMIN_ROLE).await })
|
||||
})
|
||||
.await?
|
||||
{
|
||||
// TODO prettier error page
|
||||
return Ok((StatusCode::FORBIDDEN, "You are not an administrator.").into_response());
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
/// Reflectable version of [`UserInfo`]
|
||||
#[derive(Reflect)]
|
||||
struct DisplayUserInfo {
|
||||
pub user_name: String,
|
||||
pub user_id: String,
|
||||
pub locked: bool,
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<UserInfo> for DisplayUserInfo {
|
||||
fn from(value: UserInfo) -> Self {
|
||||
let UserInfo {
|
||||
user_name,
|
||||
user_id,
|
||||
locked,
|
||||
roles,
|
||||
} = value;
|
||||
|
||||
Self {
|
||||
user_name,
|
||||
user_id: user_id.to_string(),
|
||||
locked,
|
||||
roles,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_admin_users(
|
||||
ambient: Ambient,
|
||||
Extension(store): Extension<Arc<IdCoopStore>>,
|
||||
DesiredLocale(locale): DesiredLocale,
|
||||
) -> WebResult<Response> {
|
||||
let users = store
|
||||
.txn(|mut txn| Box::pin(async move { txn.list_user_info().await }))
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(DisplayUserInfo::from)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(
|
||||
Rendered(render_template_string!(TEMPLATING, admin_users, locale, {
|
||||
ambient,
|
||||
users
|
||||
}))
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reflectable version of [`User`]
|
||||
#[derive(Reflect)]
|
||||
struct DisplayUser {
|
||||
pub user_id: String,
|
||||
pub user_name: String,
|
||||
pub created_at_utc: String,
|
||||
pub has_password: bool,
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
impl From<User> for DisplayUser {
|
||||
fn from(value: User) -> Self {
|
||||
let User {
|
||||
user_id,
|
||||
user_name,
|
||||
created_at_utc,
|
||||
password_hash,
|
||||
locked,
|
||||
} = value;
|
||||
|
||||
Self {
|
||||
user_id: user_id.to_string(),
|
||||
user_name,
|
||||
created_at_utc: created_at_utc
|
||||
.and_utc()
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string(),
|
||||
has_password: password_hash.is_some(),
|
||||
locked,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn admin_get_user(
|
||||
ambient: Ambient,
|
||||
Path(user_id): Path<Uuid>,
|
||||
Extension(store): Extension<Arc<IdCoopStore>>,
|
||||
Extension(clock): Extension<Clock>,
|
||||
DesiredLocale(locale): DesiredLocale,
|
||||
login_session: LoginSession,
|
||||
) -> WebResult<Response> {
|
||||
let user_opt = store
|
||||
.txn(|mut txn| {
|
||||
Box::pin(async move {
|
||||
let Some(user) = txn.lookup_user_by_id(user_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let active_role_ids = txn.get_user_role_ids(user.user_id).await?;
|
||||
|
||||
let all_roles = txn.list_role_info().await?;
|
||||
|
||||
let (active_roles, inactive_roles): (Vec<RoleInfo>, Vec<RoleInfo>) = all_roles
|
||||
.into_iter()
|
||||
.partition(|role| active_role_ids.contains(&role.role_id));
|
||||
|
||||
Ok(Some((user, active_roles, inactive_roles)))
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
let Some((user, active_roles, inactive_roles)) = user_opt else {
|
||||
// TODO(pretty_error)
|
||||
return Ok((StatusCode::NOT_FOUND, "No such user.").into_response());
|
||||
};
|
||||
|
||||
let xsrf_token = login_session
|
||||
.generate_xsrf_token(clock.now_utc())
|
||||
.expect("must be able to create a XSRF token");
|
||||
Ok(
|
||||
Rendered(render_template_string!(TEMPLATING, admin_user, locale, {
|
||||
ambient,
|
||||
user: DisplayUser::from(user),
|
||||
active_roles,
|
||||
inactive_roles,
|
||||
xsrf_token
|
||||
}))
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PostUser {
|
||||
xsrf: String,
|
||||
#[serde(flatten)]
|
||||
action: PostUserAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum PostUserAction {
|
||||
SetLocked { set_locked: String },
|
||||
RemoveRole { remove_role: String },
|
||||
}
|
||||
|
||||
async fn admin_post_user(
|
||||
ambient: Ambient,
|
||||
Path(user_id): Path<Uuid>,
|
||||
Extension(store): Extension<Arc<IdCoopStore>>,
|
||||
Extension(clock): Extension<Clock>,
|
||||
locale: DesiredLocale,
|
||||
login_session: LoginSession,
|
||||
Form(form): Form<PostUser>,
|
||||
) -> WebResult<Response> {
|
||||
if login_session
|
||||
.validate_xsrf_token(&form.xsrf, clock.now_utc())
|
||||
.is_err()
|
||||
{
|
||||
// XSRF token not valid
|
||||
// TODO at least acknowledge the issue...
|
||||
return admin_get_user(
|
||||
ambient,
|
||||
Path(user_id),
|
||||
Extension(store),
|
||||
Extension(clock),
|
||||
locale,
|
||||
login_session,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
match form.action {
|
||||
PostUserAction::SetLocked { set_locked } => {
|
||||
let locked = match set_locked.as_str() {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_other => {
|
||||
return Err(eyre!("invalid set_locked").into());
|
||||
}
|
||||
};
|
||||
store
|
||||
.txn(|mut txn| Box::pin(async move { txn.set_user_locked(user_id, locked).await }))
|
||||
.await?;
|
||||
}
|
||||
PostUserAction::RemoveRole { remove_role } => {
|
||||
store
|
||||
.txn(|mut txn| {
|
||||
Box::pin(async move { txn.remove_user_from_role(user_id, &remove_role).await })
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
admin_get_user(
|
||||
ambient,
|
||||
Path(user_id),
|
||||
Extension(store),
|
||||
Extension(clock),
|
||||
locale,
|
||||
login_session,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(formbeam_derive::Form)]
|
||||
struct SetPasswordForm {
|
||||
#[form(min_chars(6), max_chars(256))]
|
||||
password: String,
|
||||
|
||||
/// Hidden XSRF token, used to check this request isn't being made from another site
|
||||
/// (i.e. protects against cross-site request forgery)
|
||||
xsrf: String,
|
||||
}
|
||||
|
||||
async fn admin_get_set_password(
|
||||
ambient: Ambient,
|
||||
Path(user_id): Path<Uuid>,
|
||||
Extension(store): Extension<Arc<IdCoopStore>>,
|
||||
login_session: LoginSession,
|
||||
Extension(clock): Extension<Clock>,
|
||||
DesiredLocale(locale): DesiredLocale,
|
||||
) -> WebResult<Response> {
|
||||
let Some(user) = store
|
||||
.txn(|mut txn| Box::pin(async move { txn.lookup_user_by_id(user_id).await }))
|
||||
.await?
|
||||
else {
|
||||
// TODO(pretty_error)
|
||||
return Ok((StatusCode::NOT_FOUND, "No such user.").into_response());
|
||||
};
|
||||
|
||||
let form = ReflectedForm::<SetPasswordFormRaw>::default();
|
||||
|
||||
let xsrf_token = login_session
|
||||
.generate_xsrf_token(clock.now_utc())
|
||||
.expect("must be able to create a XSRF token");
|
||||
Ok(Rendered(
|
||||
render_template_string!(TEMPLATING, admin_user_set_password, locale, {
|
||||
ambient,
|
||||
user: DisplayUser::from(user),
|
||||
form,
|
||||
xsrf_token
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn admin_post_set_password(
|
||||
ambient: Ambient,
|
||||
Path(user_id): Path<Uuid>,
|
||||
Extension(store): Extension<Arc<IdCoopStore>>,
|
||||
DesiredLocale(locale): DesiredLocale,
|
||||
login_session: LoginSession,
|
||||
Extension(clock): Extension<Clock>,
|
||||
Extension(config): Extension<Arc<Configuration>>,
|
||||
Form(form_raw): Form<SetPasswordFormRaw>,
|
||||
) -> WebResult<Response> {
|
||||
let Some(user) = store
|
||||
.txn(|mut txn| Box::pin(async move { txn.lookup_user_by_id(user_id).await }))
|
||||
.await?
|
||||
else {
|
||||
// TODO(pretty_error)
|
||||
return Ok((StatusCode::NOT_FOUND, "No such user.").into_response());
|
||||
};
|
||||
|
||||
let mut validation = form_raw
|
||||
.validate()
|
||||
.await
|
||||
.context("failed to run form validator")?;
|
||||
if !validation.is_valid() {
|
||||
let form = ReflectedForm::new(form_raw, validation);
|
||||
let xsrf_token = login_session
|
||||
.generate_xsrf_token(clock.now_utc())
|
||||
.expect("must be able to create a XSRF token");
|
||||
// TODO Need to update fallback form
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Rendered(render_template_string!(TEMPLATING, login, locale, {
|
||||
xsrf_token,
|
||||
form,
|
||||
ambient
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// INVARIANT: Form fields are syntactically valid at this point (but note XSRF not checked)
|
||||
|
||||
let form = form_raw
|
||||
.form()
|
||||
.map_err(|e| eyre!("failed to extract a so-called valid form: {e}"))?;
|
||||
|
||||
if login_session
|
||||
.validate_xsrf_token(&form.xsrf, clock.now_utc())
|
||||
.is_err()
|
||||
{
|
||||
// Invalid XSRF token: try again
|
||||
validation.xsrf.push(errors::xsrf_invalid());
|
||||
|
||||
let form = ReflectedForm::new(form_raw, validation);
|
||||
let xsrf_token = login_session
|
||||
.generate_xsrf_token(clock.now_utc())
|
||||
.expect("must be able to create a XSRF token");
|
||||
// TODO Need to update fallback form
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Rendered(render_template_string!(TEMPLATING, login, locale, {
|
||||
xsrf_token,
|
||||
form,
|
||||
ambient
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// INVARIANT: Form validated at this point
|
||||
|
||||
let new_password_hash = create_password_hash(form.password.trim(), &config.password_hashing)
|
||||
.context("unable to hash password!")?;
|
||||
|
||||
store
|
||||
.txn(|mut txn| {
|
||||
Box::pin(async move {
|
||||
txn.change_user_password(user.user_id, Some(new_password_hash))
|
||||
.await
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::FOUND,
|
||||
[("Location", format!("/admin/users/{user_id}"))],
|
||||
"Successfully set user password. Taking you back to the user editor.",
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn admin_get_add_roles(
|
||||
ambient: Ambient,
|
||||
Path(user_id): Path<Uuid>,
|
||||
Extension(store): Extension<Arc<IdCoopStore>>,
|
||||
login_session: LoginSession,
|
||||
Extension(clock): Extension<Clock>,
|
||||
DesiredLocale(locale): DesiredLocale,
|
||||
) -> WebResult<Response> {
|
||||
let Some((user, available_roles)) = store
|
||||
.txn(|mut txn| {
|
||||
Box::pin(async move {
|
||||
let Some(user) = txn.lookup_user_by_id(user_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let active_role_ids = txn.get_user_role_ids(user.user_id).await?;
|
||||
|
||||
let all_roles = txn.list_role_info().await?;
|
||||
|
||||
let (_active_roles, inactive_roles): (Vec<RoleInfo>, Vec<RoleInfo>) = all_roles
|
||||
.into_iter()
|
||||
.partition(|role| active_role_ids.contains(&role.role_id));
|
||||
|
||||
Ok(Some((user, inactive_roles)))
|
||||
})
|
||||
})
|
||||
.await?
|
||||
else {
|
||||
// TODO(pretty_error)
|
||||
return Ok((StatusCode::NOT_FOUND, "No such user.").into_response());
|
||||
};
|
||||
|
||||
let xsrf_token = login_session
|
||||
.generate_xsrf_token(clock.now_utc())
|
||||
.expect("must be able to create a XSRF token");
|
||||
Ok(Rendered(
|
||||
render_template_string!(TEMPLATING, admin_user_add_roles, locale, {
|
||||
ambient,
|
||||
user: DisplayUser::from(user),
|
||||
xsrf_token,
|
||||
available_roles
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AddRoleForm {
|
||||
xsrf: String,
|
||||
add_role: String,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn admin_post_add_roles(
|
||||
ambient: Ambient,
|
||||
Path(user_id): Path<Uuid>,
|
||||
Extension(store): Extension<Arc<IdCoopStore>>,
|
||||
DesiredLocale(locale): DesiredLocale,
|
||||
login_session: LoginSession,
|
||||
Extension(clock): Extension<Clock>,
|
||||
Form(form): Form<AddRoleForm>,
|
||||
) -> WebResult<Response> {
|
||||
let Some(user) = store
|
||||
.txn(|mut txn| Box::pin(async move { txn.lookup_user_by_id(user_id).await }))
|
||||
.await?
|
||||
else {
|
||||
// TODO(pretty_error)
|
||||
return Ok((StatusCode::NOT_FOUND, "No such user.").into_response());
|
||||
};
|
||||
|
||||
if login_session
|
||||
.validate_xsrf_token(&form.xsrf, clock.now_utc())
|
||||
.is_err()
|
||||
{
|
||||
return admin_get_add_roles(
|
||||
ambient,
|
||||
Path(user_id),
|
||||
Extension(store),
|
||||
login_session,
|
||||
Extension(clock),
|
||||
DesiredLocale(locale),
|
||||
)
|
||||
.await
|
||||
.map(|r| (StatusCode::BAD_REQUEST, r).into_response());
|
||||
}
|
||||
|
||||
// INVARIANT: Form validated at this point
|
||||
|
||||
// TODO should probably verify the role exists without relying solely on FKs
|
||||
store
|
||||
.txn(|mut txn| {
|
||||
Box::pin(async move { txn.add_user_to_role(user.user_id, &form.add_role).await })
|
||||
})
|
||||
.await?;
|
||||
|
||||
admin_get_add_roles(
|
||||
ambient,
|
||||
Path(user_id),
|
||||
Extension(store),
|
||||
login_session,
|
||||
Extension(clock),
|
||||
DesiredLocale(locale),
|
||||
)
|
||||
.await
|
||||
}
|
13
src_scss/_admin.scss
Normal file
13
src_scss/_admin.scss
Normal file
@ -0,0 +1,13 @@
|
||||
.admin_obj_field {
|
||||
padding: 2em;
|
||||
|
||||
> :first-child {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid grey;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
|
||||
> * {
|
||||
padding: 0 0.5em 0 0.5em;
|
||||
}
|
||||
}
|
@ -11,11 +11,15 @@ body.centred main {
|
||||
max-width: 95vw;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
max-width: min(80vw, 1280px);
|
||||
max-width: min(80vw, 1280px);
|
||||
}
|
||||
}
|
||||
|
||||
body.centred footer {
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
body.centred:not(.vcentred) main {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
body.centred footer {
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
33
src_scss/_utils.scss
Normal file
33
src_scss/_utils.scss
Normal file
@ -0,0 +1,33 @@
|
||||
form.inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
button.lowprofile.lowprofile, [role=button].lowprofile.lowprofile {
|
||||
padding: 1px 8px 1px 8px;
|
||||
}
|
||||
|
||||
button.narrow {
|
||||
width: initial;
|
||||
}
|
||||
|
||||
|
||||
// -1: 0.25em;
|
||||
// -2: 0.5em;
|
||||
// -3: 0.75em;
|
||||
// -4: 1em;
|
||||
.lmar-4 {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.bmar-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: monospace;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ralign {
|
||||
text-align: right;
|
||||
}
|
@ -54,3 +54,5 @@
|
||||
|
||||
|
||||
@use "_centred.scss";
|
||||
@use "_admin.scss";
|
||||
@use "_utils.scss";
|
||||
|
37
templates/components/AdminPage.hnb
Normal file
37
templates/components/AdminPage.hnb
Normal file
@ -0,0 +1,37 @@
|
||||
declare
|
||||
param $ambient
|
||||
|
||||
|
||||
BarePage {centred = true, vcentred = false}
|
||||
:title
|
||||
optional slot :title
|
||||
|
||||
:main
|
||||
header
|
||||
set $user = $ambient.user!
|
||||
nav
|
||||
ul
|
||||
li
|
||||
strong
|
||||
@admin_nav_brand
|
||||
|
||||
li
|
||||
a {href = "/admin/users"}
|
||||
@admin_nav_users
|
||||
ul
|
||||
li
|
||||
@logged_in_as1
|
||||
strong
|
||||
"${$user.name}"
|
||||
@logged_in_as2
|
||||
li
|
||||
form {action = "/logout", method = "POST"}
|
||||
button.outline.secondary.bmar-0 {type = "submit"}
|
||||
@header_logout
|
||||
|
||||
main
|
||||
slot :main
|
||||
|
||||
|
||||
footer
|
||||
PageFooter {$ambient}
|
@ -1,5 +1,6 @@
|
||||
declare
|
||||
param $centred = false
|
||||
param $vcentred = true
|
||||
|
||||
html {lang="en"}
|
||||
head
|
||||
@ -14,7 +15,11 @@ html {lang="en"}
|
||||
|
||||
link {rel="stylesheet", href="/static/main.css"}
|
||||
|
||||
if $centred
|
||||
// TODO should we switch to .container from pico?
|
||||
if $centred and $vcentred
|
||||
body.centred.vcentred
|
||||
slot :main
|
||||
else if $centred
|
||||
body.centred
|
||||
slot :main
|
||||
else
|
||||
|
@ -20,7 +20,7 @@ BarePage {centred = true}
|
||||
ul
|
||||
li
|
||||
form {action = "/logout", method = "POST"}
|
||||
button.outline.secondary {type = "submit"}
|
||||
button.outline.secondary.bmar-0 {type = "submit"}
|
||||
@header_logout
|
||||
|
||||
//None =>
|
||||
|
87
templates/pages/admin_user.hnb
Normal file
87
templates/pages/admin_user.hnb
Normal file
@ -0,0 +1,87 @@
|
||||
AdminPage {$ambient}
|
||||
:title
|
||||
"${$user.user_name} — "
|
||||
@admin_users_title
|
||||
|
||||
:main
|
||||
nav {aria-label = "breadcrumb"}
|
||||
ul
|
||||
li
|
||||
a {href = "/admin/users"}
|
||||
@admin_nav_users
|
||||
li
|
||||
"${$user.user_name}"
|
||||
|
||||
article
|
||||
div.grid
|
||||
div.admin_obj_field
|
||||
div#name-label
|
||||
@admin_users_attr_name
|
||||
div.font-mono {aria-labelledby = "name-label"}
|
||||
"${$user.user_name}"
|
||||
|
||||
div.admin_obj_field
|
||||
div#uuid-label
|
||||
@admin_users_attr_uuid
|
||||
div {aria-labelledby = "name-label"}
|
||||
"${$user.user_id}"
|
||||
|
||||
div.grid
|
||||
div.admin_obj_field
|
||||
div#regat-label
|
||||
@admin_users_attr_created_at
|
||||
div {aria-labelledby = "regat-label"}
|
||||
"${$user.created_at_utc}"
|
||||
|
||||
div.admin_obj_field
|
||||
div#pass-label
|
||||
@admin_users_attr_has_password
|
||||
div {aria-labelledby = "pass-label"}
|
||||
if $user.has_password
|
||||
@admin_bool_true
|
||||
else
|
||||
@admin_bool_false
|
||||
|
||||
a.outline.secondary.lowprofile.lmar-4 {href = "/admin/users/${$user.user_id}/set_password", role = "button"}
|
||||
@admin_users_btn_change_password
|
||||
|
||||
|
||||
div.admin_obj_field
|
||||
div#lock-label
|
||||
@admin_users_attr_locked
|
||||
div {aria-labelledby = "lock-label"}
|
||||
if $user.locked
|
||||
@admin_bool_true
|
||||
form.inline {method = "POST"}
|
||||
input {type = "hidden", name = "xsrf", value = $xsrf_token}
|
||||
button.outline.secondary.lowprofile.narrow.lmar-4.bmar-0 {type = "submit", name = "set_locked", value = "false"}
|
||||
@admin_users_btn_unlock
|
||||
else
|
||||
@admin_bool_false
|
||||
form.inline {method = "POST"}
|
||||
input {type = "hidden", name = "xsrf", value = $xsrf_token}
|
||||
button.outline.secondary.lowprofile.narrow.lmar-4.bmar-0 {type = "submit", name = "set_locked", value = "true"}
|
||||
@admin_users_btn_lock
|
||||
|
||||
div.admin_obj_field
|
||||
div
|
||||
"Roles"
|
||||
|
||||
div
|
||||
table.striped
|
||||
tbody
|
||||
for $role in $active_roles
|
||||
tr
|
||||
td.font-mono
|
||||
"${$role.role_id}"
|
||||
td
|
||||
"${$role.role_name}"
|
||||
td
|
||||
form.inline {method = "POST"}
|
||||
input {type = "hidden", name = "xsrf", value = $xsrf_token}
|
||||
button.secondary.outline.lowprofile.narrow.bmar-0 {type = "submit", name = "remove_role", value = "${$role.role_id}"}
|
||||
@admin_user_btn_rm_role
|
||||
|
||||
div
|
||||
a.outline.lowprofile {href = "/admin/users/${$user.user_id}/add_roles", role = "button"}
|
||||
@admin_user_btn_add_roles
|
40
templates/pages/admin_user_add_roles.hnb
Normal file
40
templates/pages/admin_user_add_roles.hnb
Normal file
@ -0,0 +1,40 @@
|
||||
AdminPage {$ambient}
|
||||
:title
|
||||
"${$user.user_name} — "
|
||||
@admin_user_add_roles_title
|
||||
|
||||
:main
|
||||
nav {aria-label = "breadcrumb"}
|
||||
ul
|
||||
li
|
||||
a {href = "/admin/users"}
|
||||
@admin_nav_users
|
||||
li
|
||||
a {href = "/admin/users/${$user.user_id}"}
|
||||
"${$user.user_name}"
|
||||
li
|
||||
@admin_user_add_roles_title
|
||||
|
||||
article
|
||||
div.admin_obj_field
|
||||
div
|
||||
@admin_user_add_roles_available
|
||||
|
||||
div
|
||||
table.striped
|
||||
tbody
|
||||
for $role in $available_roles
|
||||
tr
|
||||
td.font-mono
|
||||
"${$role.role_id}"
|
||||
td
|
||||
"${$role.role_name}"
|
||||
td
|
||||
form.inline {method = "POST"}
|
||||
input {type = "hidden", name = "xsrf", value = $xsrf_token}
|
||||
button.secondary.outline.lowprofile.narrow.bmar-0 {type = "submit", name = "add_role", value = "${$role.role_id}"}
|
||||
@admin_user_add_roles_btn_add
|
||||
|
||||
div.ralign
|
||||
a.lowprofile {href = "/admin/users/${$user.user_id}", role = "button"}
|
||||
@admin_user_add_roles_finish
|
39
templates/pages/admin_user_set_password.hnb
Normal file
39
templates/pages/admin_user_set_password.hnb
Normal file
@ -0,0 +1,39 @@
|
||||
AdminPage {$ambient}
|
||||
:title
|
||||
"${$user.user_name} — "
|
||||
@admin_users_btn_change_password
|
||||
|
||||
:main
|
||||
nav {aria-label = "breadcrumb"}
|
||||
ul
|
||||
li
|
||||
a {href = "/admin/users"}
|
||||
@admin_nav_users
|
||||
li
|
||||
a {href = "/admin/users/${$user.user_id}"}
|
||||
"${$user.user_name}"
|
||||
li
|
||||
@admin_users_btn_change_password
|
||||
|
||||
article
|
||||
form {method = "POST"}
|
||||
set $errs = $form.errors.form_wide
|
||||
if $errs.len() != 0
|
||||
@form_errors{count = $errs.len()}
|
||||
|
||||
ul
|
||||
for $err in $errs
|
||||
li
|
||||
@login_error{errcode = $err.error_code()}
|
||||
fieldset
|
||||
label
|
||||
@form_username
|
||||
input {type = "text", value = "${$user.user_name}", disabled = "true"}
|
||||
|
||||
label
|
||||
@admin_form_new_password
|
||||
Text {$form, name = "password", type = "password"}
|
||||
|
||||
input {type = "hidden", name = "xsrf", value = $xsrf_token}
|
||||
button {type = "submit"}
|
||||
@admin_users_btn_change_password
|
48
templates/pages/admin_users.hnb
Normal file
48
templates/pages/admin_users.hnb
Normal file
@ -0,0 +1,48 @@
|
||||
AdminPage {$ambient}
|
||||
:title
|
||||
@admin_users_title
|
||||
|
||||
:main
|
||||
h1
|
||||
@admin_users_title
|
||||
|
||||
table.striped
|
||||
thead
|
||||
tr
|
||||
th
|
||||
@admin_users_attr_name
|
||||
th {aria-hidden = "true"}
|
||||
@admin_users_attr_uuid
|
||||
th
|
||||
@admin_users_attr_locked
|
||||
th
|
||||
@admin_users_attr_roles
|
||||
|
||||
tbody
|
||||
for $user in $users
|
||||
tr
|
||||
td
|
||||
a {href = "/admin/users/${$user.user_id}"}
|
||||
"${$user.user_name}"
|
||||
|
||||
td {aria-hidden = "true"}
|
||||
a {href = "/admin/users/${$user.user_id}"}
|
||||
"${$user.user_id}"
|
||||
|
||||
if $user.locked
|
||||
td {aria-label = "@admin_users_attr_sr_locked"}
|
||||
@admin_bool_true
|
||||
else
|
||||
td {aria-hidden = "true"}
|
||||
@admin_bool_false
|
||||
|
||||
td
|
||||
set $first = true
|
||||
for $role in $user.roles
|
||||
// not unimplemented
|
||||
if $first == false
|
||||
", "
|
||||
set $first = false
|
||||
span.font-mono
|
||||
"${$role}"
|
||||
|
34
translations/en/admin.ftl
Normal file
34
translations/en/admin.ftl
Normal file
@ -0,0 +1,34 @@
|
||||
admin_nav_brand = idCoop Admin
|
||||
admin_nav_users = Users
|
||||
|
||||
admin_users_title = Manage users
|
||||
|
||||
|
||||
admin_users_attr_name = Name
|
||||
admin_users_attr_uuid = UUID
|
||||
admin_users_attr_locked = Locked?
|
||||
admin_users_attr_sr_locked = This user is locked
|
||||
admin_users_attr_roles = Roles
|
||||
admin_users_attr_created_at = Registered at (UTC)
|
||||
admin_users_attr_has_password = Has password?
|
||||
|
||||
admin_users_btn_lock = Lock user
|
||||
admin_users_btn_unlock = Unlock user
|
||||
admin_users_btn_change_password = Change password
|
||||
|
||||
admin_bool_true = yes
|
||||
admin_bool_false = no
|
||||
|
||||
|
||||
admin_user_btn_add_roles = Add roles
|
||||
admin_user_btn_rm_role = Remove
|
||||
|
||||
admin_form_new_password = New password
|
||||
|
||||
|
||||
|
||||
admin_user_add_roles_title = Add roles
|
||||
admin_user_add_roles_btn_add = Add role
|
||||
admin_user_add_roles_available = Available Roles
|
||||
admin_user_add_roles_finish = Finish
|
||||
|
Loading…
x
Reference in New Issue
Block a user