diff --git a/.sqlx/query-905f7524977b1cb7b8e78f4671ae59f21a82a9a2944a98712dd18a1aea9d9c8d.json b/.sqlx/query-905f7524977b1cb7b8e78f4671ae59f21a82a9a2944a98712dd18a1aea9d9c8d.json new file mode 100644 index 0000000..8b43d96 --- /dev/null +++ b/.sqlx/query-905f7524977b1cb7b8e78f4671ae59f21a82a9a2944a98712dd18a1aea9d9c8d.json @@ -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" +} diff --git a/.sqlx/query-90b1d6e644b5e5d3b19bf96f3b3d8b93948d3e9311f50a5da3c5f8b23d8998b8.json b/.sqlx/query-90b1d6e644b5e5d3b19bf96f3b3d8b93948d3e9311f50a5da3c5f8b23d8998b8.json new file mode 100644 index 0000000..14c07a3 --- /dev/null +++ b/.sqlx/query-90b1d6e644b5e5d3b19bf96f3b3d8b93948d3e9311f50a5da3c5f8b23d8998b8.json @@ -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" +} diff --git a/src/store.rs b/src/store.rs index 6fe2a44..0e02f40 100644 --- a/src/store.rs +++ b/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> { + 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 { + 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) + } } diff --git a/src/web.rs b/src/web.rs index a51dcdf..3483463 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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( diff --git a/src/web/admin_panel.rs b/src/web/admin_panel.rs new file mode 100644 index 0000000..5f8172b --- /dev/null +++ b/src/web/admin_panel.rs @@ -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, + Extension(store): Extension>, + OriginalUri(uri): OriginalUri, + request: Request, + next: Next, +) -> WebResult { + 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, +} + +impl From 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>, + DesiredLocale(locale): DesiredLocale, +) -> WebResult { + let users = store + .txn(|mut txn| Box::pin(async move { txn.list_user_info().await })) + .await? + .into_iter() + .map(DisplayUserInfo::from) + .collect::>(); + 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 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, + Extension(store): Extension>, + Extension(clock): Extension, + DesiredLocale(locale): DesiredLocale, + login_session: LoginSession, +) -> WebResult { + 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, Vec) = 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, + Extension(store): Extension>, + Extension(clock): Extension, + locale: DesiredLocale, + login_session: LoginSession, + Form(form): Form, +) -> WebResult { + 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, + Extension(store): Extension>, + login_session: LoginSession, + Extension(clock): Extension, + DesiredLocale(locale): DesiredLocale, +) -> WebResult { + 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::::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, + Extension(store): Extension>, + DesiredLocale(locale): DesiredLocale, + login_session: LoginSession, + Extension(clock): Extension, + Extension(config): Extension>, + Form(form_raw): Form, +) -> WebResult { + 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, + Extension(store): Extension>, + login_session: LoginSession, + Extension(clock): Extension, + DesiredLocale(locale): DesiredLocale, +) -> WebResult { + 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, Vec) = 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, + Extension(store): Extension>, + DesiredLocale(locale): DesiredLocale, + login_session: LoginSession, + Extension(clock): Extension, + Form(form): Form, +) -> WebResult { + 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 +} diff --git a/src_scss/_admin.scss b/src_scss/_admin.scss new file mode 100644 index 0000000..b5450c9 --- /dev/null +++ b/src_scss/_admin.scss @@ -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; + } +} diff --git a/src_scss/_centred.scss b/src_scss/_centred.scss index d8540bd..a17e34b 100644 --- a/src_scss/_centred.scss +++ b/src_scss/_centred.scss @@ -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; } diff --git a/src_scss/_utils.scss b/src_scss/_utils.scss new file mode 100644 index 0000000..119a69b --- /dev/null +++ b/src_scss/_utils.scss @@ -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; +} diff --git a/src_scss/main.scss b/src_scss/main.scss index 12fda55..eeb2f41 100644 --- a/src_scss/main.scss +++ b/src_scss/main.scss @@ -54,3 +54,5 @@ @use "_centred.scss"; +@use "_admin.scss"; +@use "_utils.scss"; diff --git a/templates/components/AdminPage.hnb b/templates/components/AdminPage.hnb new file mode 100644 index 0000000..cf4a94f --- /dev/null +++ b/templates/components/AdminPage.hnb @@ -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} diff --git a/templates/components/BarePage.hnb b/templates/components/BarePage.hnb index 078afc6..17b8fa2 100644 --- a/templates/components/BarePage.hnb +++ b/templates/components/BarePage.hnb @@ -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 diff --git a/templates/components/CentredPage.hnb b/templates/components/CentredPage.hnb index d4eecb9..79672dd 100644 --- a/templates/components/CentredPage.hnb +++ b/templates/components/CentredPage.hnb @@ -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 => diff --git a/templates/pages/admin_user.hnb b/templates/pages/admin_user.hnb new file mode 100644 index 0000000..7044790 --- /dev/null +++ b/templates/pages/admin_user.hnb @@ -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 diff --git a/templates/pages/admin_user_add_roles.hnb b/templates/pages/admin_user_add_roles.hnb new file mode 100644 index 0000000..ef972d6 --- /dev/null +++ b/templates/pages/admin_user_add_roles.hnb @@ -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 diff --git a/templates/pages/admin_user_set_password.hnb b/templates/pages/admin_user_set_password.hnb new file mode 100644 index 0000000..7555259 --- /dev/null +++ b/templates/pages/admin_user_set_password.hnb @@ -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 diff --git a/templates/pages/admin_users.hnb b/templates/pages/admin_users.hnb new file mode 100644 index 0000000..a15d9b8 --- /dev/null +++ b/templates/pages/admin_users.hnb @@ -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}" + diff --git a/translations/en/admin.ftl b/translations/en/admin.ftl new file mode 100644 index 0000000..37e816e --- /dev/null +++ b/translations/en/admin.ftl @@ -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 +