Add admin panel with basic user management operations

This commit is contained in:
Olivier 'reivilibre 2025-07-08 21:49:27 +01:00
parent 46058b2943
commit e322da0185
17 changed files with 966 additions and 7 deletions

View 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"
}

View 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"
}

View File

@ -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)
}
}

View File

@ -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
View 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
View 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;
}
}

View File

@ -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
View 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;
}

View File

@ -54,3 +54,5 @@
@use "_centred.scss";
@use "_admin.scss";
@use "_utils.scss";

View 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}

View File

@ -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

View File

@ -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 =>

View 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

View 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

View 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

View 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
View 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