diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index dda062b..3f915d1 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,4 +1,4 @@
-FROM rust:1.72
+FROM rust:1.74
ARG USERNAME=lldapdev
# We need to keep the user as 1001 to match the GitHub runner's UID.
diff --git a/app/queries/create_user_attribute.graphql b/app/queries/create_user_attribute.graphql
new file mode 100644
index 0000000..bdd6466
--- /dev/null
+++ b/app/queries/create_user_attribute.graphql
@@ -0,0 +1,5 @@
+mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
+ addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
+ ok
+ }
+}
\ No newline at end of file
diff --git a/app/queries/delete_user_attribute.graphql b/app/queries/delete_user_attribute.graphql
new file mode 100644
index 0000000..9e0d31d
--- /dev/null
+++ b/app/queries/delete_user_attribute.graphql
@@ -0,0 +1,5 @@
+mutation DeleteUserAttributeQuery($name: String!) {
+ deleteUserAttribute(name: $name) {
+ ok
+ }
+}
\ No newline at end of file
diff --git a/app/queries/get_user_attributes_schema.graphql b/app/queries/get_user_attributes_schema.graphql
new file mode 100644
index 0000000..0560285
--- /dev/null
+++ b/app/queries/get_user_attributes_schema.graphql
@@ -0,0 +1,14 @@
+query GetUserAttributesSchema {
+ schema {
+ userSchema {
+ attributes {
+ name
+ attributeType
+ isList
+ isVisible
+ isEditable
+ isHardcoded
+ }
+ }
+ }
+}
diff --git a/app/src/components/app.rs b/app/src/components/app.rs
index 1721cc7..c7e503f 100644
--- a/app/src/components/app.rs
+++ b/app/src/components/app.rs
@@ -3,6 +3,7 @@ use crate::{
change_password::ChangePasswordForm,
create_group::CreateGroupForm,
create_user::CreateUserForm,
+ create_user_attribute::CreateUserAttributeForm,
group_details::GroupDetails,
group_table::GroupTable,
login::LoginForm,
@@ -10,6 +11,7 @@ use crate::{
reset_password_step1::ResetPasswordStep1Form,
reset_password_step2::ResetPasswordStep2Form,
router::{AppRoute, Link, Redirect},
+ user_attributes_table::UserAttributesTable,
user_details::UserDetails,
user_table::UserTable,
},
@@ -227,6 +229,9 @@ impl App {
AppRoute::CreateGroup => html! {
},
+ AppRoute::CreateUserAttribute => html! {
+
+ },
AppRoute::ListGroups => html! {
@@ -236,6 +241,15 @@ impl App {
},
+ AppRoute::ListUserAttributes => html! {
+
+
+
+
+ {"Create an attribute"}
+
+
+ },
AppRoute::GroupDetails { group_id } => html! {
},
@@ -291,6 +305,14 @@ impl App {
{"Groups"}
+
+
+
+ {"User attributes"}
+
+
>
} } else { html!{} } }
diff --git a/app/src/components/create_user_attribute.rs b/app/src/components/create_user_attribute.rs
new file mode 100644
index 0000000..0e0f479
--- /dev/null
+++ b/app/src/components/create_user_attribute.rs
@@ -0,0 +1,235 @@
+use crate::{
+ components::router::AppRoute,
+ infra::common_component::{CommonComponent, CommonComponentParts},
+};
+use anyhow::{bail, Result};
+use gloo_console::log;
+use graphql_client::GraphQLQuery;
+use validator_derive::Validate;
+use yew::prelude::*;
+use yew_form_derive::Model;
+use yew_router::{prelude::History, scope_ext::RouterScopeExt};
+
+#[derive(GraphQLQuery)]
+#[graphql(
+ schema_path = "../schema.graphql",
+ query_path = "queries/create_user_attribute.graphql",
+ response_derives = "Debug",
+ custom_scalars_module = "crate::infra::graphql"
+)]
+pub struct CreateUserAttribute;
+
+type AttributeType = create_user_attribute::AttributeType;
+
+pub struct CreateUserAttributeForm {
+ common: CommonComponentParts,
+ form: yew_form::Form,
+}
+
+#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
+pub struct CreateUserAttributeModel {
+ #[validate(length(min = 1, message = "attribute_name is required"))]
+ attribute_name: String,
+ #[validate(length(min = 1, message = "attribute_type is required"))]
+ attribute_type: String,
+ is_editable: bool,
+ is_list: bool,
+ is_visible: bool,
+}
+
+pub enum Msg {
+ Update,
+ SubmitForm,
+ CreateUserAttributeResponse(Result),
+}
+
+impl CommonComponent for CreateUserAttributeForm {
+ fn handle_msg(
+ &mut self,
+ ctx: &Context,
+ msg: ::Message,
+ ) -> Result {
+ match msg {
+ Msg::Update => Ok(true),
+ Msg::SubmitForm => {
+ if !self.form.validate() {
+ bail!("Check the form for errors");
+ }
+ let model = self.form.model();
+ if model.is_editable && !model.is_visible {
+ bail!("Editable attributes must also be visible");
+ }
+ let attribute_type = match model.attribute_type.as_str() {
+ "Jpeg" => AttributeType::JPEG_PHOTO,
+ "DateTime" => AttributeType::DATE_TIME,
+ "Integer" => AttributeType::INTEGER,
+ "String" => AttributeType::STRING,
+ _ => bail!("Check the form for errors"),
+ };
+ let req = create_user_attribute::Variables {
+ name: model.attribute_name,
+ attribute_type: attribute_type,
+ is_editable: model.is_editable,
+ is_list: model.is_list,
+ is_visible: model.is_visible,
+ };
+ self.common.call_graphql::(
+ ctx,
+ req,
+ Msg::CreateUserAttributeResponse,
+ "Error trying to create user attribute",
+ );
+ Ok(true)
+ }
+ Msg::CreateUserAttributeResponse(response) => {
+ response?;
+ let model = self.form.model();
+ log!(&format!(
+ "Created user attribute '{}'",
+ model.attribute_name
+ ));
+ ctx.link()
+ .history()
+ .unwrap()
+ .push(AppRoute::ListUserAttributes);
+ Ok(true)
+ }
+ }
+ }
+
+ fn mut_common(&mut self) -> &mut CommonComponentParts {
+ &mut self.common
+ }
+}
+
+impl Component for CreateUserAttributeForm {
+ type Message = Msg;
+ type Properties = ();
+
+ fn create(_: &Context) -> Self {
+ let mut model = CreateUserAttributeModel::default();
+ model.attribute_type = "String".to_string();
+ Self {
+ common: CommonComponentParts::::create(),
+ form: yew_form::Form::::new(model),
+ }
+ }
+
+ fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool {
+ CommonComponentParts::::update(self, ctx, msg)
+ }
+
+ fn view(&self, ctx: &Context) -> Html {
+ let link = ctx.link();
+ type Field = yew_form::Field;
+ type Select = yew_form::Select;
+ type Checkbox = yew_form::CheckBox;
+ html! {
+
+
+ { if let Some(e) = &self.common.error {
+ html! {
+
+ {e.to_string() }
+
+ }
+ } else { html! {} }
+ }
+
+ }
+ }
+}
diff --git a/app/src/components/delete_user_attribute.rs b/app/src/components/delete_user_attribute.rs
new file mode 100644
index 0000000..254424d
--- /dev/null
+++ b/app/src/components/delete_user_attribute.rs
@@ -0,0 +1,172 @@
+use crate::infra::{
+ common_component::{CommonComponent, CommonComponentParts},
+ modal::Modal,
+};
+use anyhow::{Error, Result};
+use graphql_client::GraphQLQuery;
+use yew::prelude::*;
+
+#[derive(GraphQLQuery)]
+#[graphql(
+ schema_path = "../schema.graphql",
+ query_path = "queries/delete_user_attribute.graphql",
+ response_derives = "Debug",
+ custom_scalars_module = "crate::infra::graphql"
+)]
+pub struct DeleteUserAttributeQuery;
+
+pub struct DeleteUserAttribute {
+ common: CommonComponentParts,
+ node_ref: NodeRef,
+ modal: Option,
+}
+
+#[derive(yew::Properties, Clone, PartialEq, Debug)]
+pub struct DeleteUserAttributeProps {
+ pub attribute_name: String,
+ pub on_attribute_deleted: Callback,
+ pub on_error: Callback,
+}
+
+pub enum Msg {
+ ClickedDeleteUserAttribute,
+ ConfirmDeleteUserAttribute,
+ DismissModal,
+ DeleteUserAttributeResponse(Result),
+}
+
+impl CommonComponent for DeleteUserAttribute {
+ fn handle_msg(
+ &mut self,
+ ctx: &Context,
+ msg: ::Message,
+ ) -> Result {
+ match msg {
+ Msg::ClickedDeleteUserAttribute => {
+ self.modal.as_ref().expect("modal not initialized").show();
+ }
+ Msg::ConfirmDeleteUserAttribute => {
+ self.update(ctx, Msg::DismissModal);
+ self.common.call_graphql::(
+ ctx,
+ delete_user_attribute_query::Variables {
+ name: ctx.props().attribute_name.clone(),
+ },
+ Msg::DeleteUserAttributeResponse,
+ "Error trying to delete user attribute",
+ );
+ }
+ Msg::DismissModal => {
+ self.modal.as_ref().expect("modal not initialized").hide();
+ }
+ Msg::DeleteUserAttributeResponse(response) => {
+ response?;
+ ctx.props()
+ .on_attribute_deleted
+ .emit(ctx.props().attribute_name.clone());
+ }
+ }
+ Ok(true)
+ }
+
+ fn mut_common(&mut self) -> &mut CommonComponentParts {
+ &mut self.common
+ }
+}
+
+impl Component for DeleteUserAttribute {
+ type Message = Msg;
+ type Properties = DeleteUserAttributeProps;
+
+ fn create(_: &Context) -> Self {
+ Self {
+ common: CommonComponentParts::::create(),
+ node_ref: NodeRef::default(),
+ modal: None,
+ }
+ }
+
+ fn rendered(&mut self, _: &Context, first_render: bool) {
+ if first_render {
+ self.modal = Some(Modal::new(
+ self.node_ref
+ .cast::()
+ .expect("Modal node is not an element"),
+ ));
+ }
+ }
+
+ fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool {
+ CommonComponentParts::::update_and_report_error(
+ self,
+ ctx,
+ msg,
+ ctx.props().on_error.clone(),
+ )
+ }
+
+ fn view(&self, ctx: &Context) -> Html {
+ let link = &ctx.link();
+ html! {
+ <>
+
+ {self.show_modal(ctx)}
+ >
+ }
+ }
+}
+
+impl DeleteUserAttribute {
+ fn show_modal(&self, ctx: &Context) -> Html {
+ let link = &ctx.link();
+ html! {
+
+
+
+
+
+
+ {"Are you sure you want to delete user attribute "}
+ {&ctx.props().attribute_name}{"?"}
+
+
+
+
+
+
+ }
+ }
+}
diff --git a/app/src/components/mod.rs b/app/src/components/mod.rs
index f78dcf9..26e01ed 100644
--- a/app/src/components/mod.rs
+++ b/app/src/components/mod.rs
@@ -4,8 +4,10 @@ pub mod app;
pub mod change_password;
pub mod create_group;
pub mod create_user;
+pub mod create_user_attribute;
pub mod delete_group;
pub mod delete_user;
+pub mod delete_user_attribute;
pub mod group_details;
pub mod group_table;
pub mod login;
@@ -15,6 +17,7 @@ pub mod reset_password_step1;
pub mod reset_password_step2;
pub mod router;
pub mod select;
+pub mod user_attributes_table;
pub mod user_details;
pub mod user_details_form;
pub mod user_table;
diff --git a/app/src/components/router.rs b/app/src/components/router.rs
index 3b03b61..7ff3174 100644
--- a/app/src/components/router.rs
+++ b/app/src/components/router.rs
@@ -24,6 +24,10 @@ pub enum AppRoute {
GroupDetails { group_id: i64 },
#[at("/")]
Index,
+ #[at("/user-attributes")]
+ ListUserAttributes,
+ #[at("/user-attributes/create")]
+ CreateUserAttribute,
}
pub type Link = yew_router::components::Link;
diff --git a/app/src/components/user_attributes_table.rs b/app/src/components/user_attributes_table.rs
new file mode 100644
index 0000000..e0658e5
--- /dev/null
+++ b/app/src/components/user_attributes_table.rs
@@ -0,0 +1,160 @@
+use crate::{
+ components::delete_user_attribute::DeleteUserAttribute,
+ infra::common_component::{CommonComponent, CommonComponentParts},
+};
+use anyhow::{Error, Result};
+use graphql_client::GraphQLQuery;
+use yew::prelude::*;
+
+#[derive(GraphQLQuery)]
+#[graphql(
+ schema_path = "../schema.graphql",
+ query_path = "queries/get_user_attributes_schema.graphql",
+ response_derives = "Debug,Clone,PartialEq,Eq",
+ custom_scalars_module = "crate::infra::graphql"
+)]
+pub struct GetUserAttributesSchema;
+
+use get_user_attributes_schema::ResponseData;
+
+pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
+pub type AttributeType = get_user_attributes_schema::AttributeType;
+
+pub struct UserAttributesTable {
+ common: CommonComponentParts,
+ attributes: Option>,
+}
+
+pub enum Msg {
+ ListAttributesResponse(Result),
+ OnAttributeDeleted(String),
+ OnError(Error),
+}
+
+impl CommonComponent for UserAttributesTable {
+ fn handle_msg(&mut self, _: &Context, msg: ::Message) -> Result {
+ match msg {
+ Msg::ListAttributesResponse(schema) => {
+ self.attributes = Some(schema?.schema.user_schema.attributes.into_iter().collect());
+ Ok(true)
+ }
+ Msg::OnError(e) => Err(e),
+ Msg::OnAttributeDeleted(attribute_name) => {
+ debug_assert!(self.attributes.is_some());
+ self.attributes
+ .as_mut()
+ .unwrap()
+ .retain(|a| a.name != attribute_name);
+ Ok(true)
+ }
+ }
+ }
+
+ fn mut_common(&mut self) -> &mut CommonComponentParts {
+ &mut self.common
+ }
+}
+
+impl Component for UserAttributesTable {
+ type Message = Msg;
+ type Properties = ();
+
+ fn create(ctx: &Context) -> Self {
+ let mut table = UserAttributesTable {
+ common: CommonComponentParts::::create(),
+ attributes: None,
+ };
+ table.common.call_graphql::(
+ ctx,
+ get_user_attributes_schema::Variables {},
+ Msg::ListAttributesResponse,
+ "Error trying to fetch user schema",
+ );
+ table
+ }
+
+ fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool {
+ CommonComponentParts::::update(self, ctx, msg)
+ }
+
+ fn view(&self, ctx: &Context) -> Html {
+ html! {
+
+ {self.view_attributes(ctx)}
+ {self.view_errors()}
+
+ }
+ }
+}
+
+impl UserAttributesTable {
+ fn view_attributes(&self, ctx: &Context) -> Html {
+ let make_table = |attributes: &Vec| {
+ html! {
+
+
+
+
+ | {"Attribute name"} |
+ {"Type"} |
+ {"Editable"} |
+ {"Visible"} |
+ {"Delete"} |
+
+
+
+ {attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::>()}
+
+
+
+ }
+ };
+ match &self.attributes {
+ None => html! {{"Loading..."}},
+ Some(attributes) => make_table(attributes),
+ }
+ }
+
+ fn view_attribute(&self, ctx: &Context, attribute: &Attribute) -> Html {
+ let link = ctx.link();
+ let attribute_type = match attribute.attribute_type {
+ AttributeType::STRING => "String",
+ AttributeType::INTEGER => "Integer",
+ AttributeType::JPEG_PHOTO => "Jpeg",
+ AttributeType::DATE_TIME => "DateTime",
+ _ => "Unknown",
+ };
+ let checkmark = html! {
+
+ };
+ html! {
+
+ | {&attribute.name} |
+ {if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}} |
+ {if attribute.is_editable {checkmark.clone()} else {html!{}}} |
+ {if attribute.is_visible {checkmark.clone()} else {html!{}}} |
+ {if attribute.is_hardcoded {html!{
+
+ }} else {html!{
+
+ }}} |
+
+ }
+ }
+
+ fn view_errors(&self) -> Html {
+ match &self.common.error {
+ None => html! {},
+ Some(e) => html! {{"Error: "}{e.to_string()}
},
+ }
+ }
+}