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..fbb48c1 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,
@@ -11,6 +12,7 @@ use crate::{
reset_password_step2::ResetPasswordStep2Form,
router::{AppRoute, Link, Redirect},
user_details::UserDetails,
+ user_schema_table::ListUserSchema,
user_table::UserTable,
},
infra::{api::HostService, cookies::get_cookie},
@@ -227,6 +229,9 @@ impl App {
AppRoute::CreateGroup => html! {
},
+ AppRoute::CreateUserAttribute => html! {
+
+ },
AppRoute::ListGroups => html! {
@@ -236,6 +241,9 @@ impl App {
},
+ AppRoute::ListUserSchema => html! {
+
+ },
AppRoute::GroupDetails { group_id } => html! {
},
@@ -291,6 +299,14 @@ impl App {
{"Groups"}
+
+
+
+ {"User schema"}
+
+
>
} } 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..569c457
--- /dev/null
+++ b/app/src/components/create_user_attribute.rs
@@ -0,0 +1,186 @@
+use std::str::FromStr;
+
+use crate::{
+ components::{
+ form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
+ router::AppRoute,
+ },
+ convert_attribute_type,
+ infra::{
+ common_component::{CommonComponent, CommonComponentParts},
+ schema::AttributeType,
+ },
+};
+use anyhow::{bail, Result};
+use gloo_console::log;
+use graphql_client::GraphQLQuery;
+use validator::ValidationError;
+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;
+
+convert_attribute_type!(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(custom = "validate_attribute_type")]
+ attribute_type: String,
+ is_editable: bool,
+ is_list: bool,
+ is_visible: bool,
+}
+
+fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
+ let result = AttributeType::from_str(attribute_type);
+ match result {
+ Ok(_) => Ok(()),
+ _ => Err(ValidationError::new("Invalid attribute type")),
+ }
+}
+
+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 = model.attribute_type.parse::().unwrap();
+ let req = create_user_attribute::Variables {
+ name: model.attribute_name,
+ attribute_type: create_user_attribute::AttributeType::from(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::ListUserSchema);
+ 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 model = CreateUserAttributeModel {
+ attribute_type: AttributeType::String.to_string(),
+ ..Default::default()
+ };
+ 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();
+ 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/form/checkbox.rs b/app/src/components/form/checkbox.rs
new file mode 100644
index 0000000..6697421
--- /dev/null
+++ b/app/src/components/form/checkbox.rs
@@ -0,0 +1,35 @@
+use yew::{function_component, html, virtual_dom::AttrValue, Callback, Properties};
+use yew_form::{Form, Model};
+
+#[derive(Properties, PartialEq)]
+pub struct Props {
+ pub label: AttrValue,
+ pub field_name: String,
+ pub form: Form,
+ #[prop_or(false)]
+ pub required: bool,
+ #[prop_or_else(Callback::noop)]
+ pub ontoggle: Callback,
+}
+
+#[function_component(CheckBox)]
+pub fn checkbox(props: &Props) -> Html {
+ html! {
+
+ }
+}
diff --git a/app/src/components/form/field.rs b/app/src/components/form/field.rs
new file mode 100644
index 0000000..ab5018f
--- /dev/null
+++ b/app/src/components/form/field.rs
@@ -0,0 +1,42 @@
+use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties};
+use yew_form::{Form, Model};
+
+#[derive(Properties, PartialEq)]
+pub struct Props {
+ pub label: AttrValue,
+ pub field_name: String,
+ pub form: Form,
+ #[prop_or(false)]
+ pub required: bool,
+ #[prop_or_else(Callback::noop)]
+ pub oninput: Callback,
+}
+
+#[function_component(Field)]
+pub fn field(props: &Props) -> Html {
+ html! {
+
+
+
+
+ form={&props.form}
+ field_name={props.field_name.clone()}
+ class="form-control"
+ class_invalid="is-invalid has-error"
+ class_valid="has-success"
+ autocomplete={props.field_name.clone()}
+ oninput={&props.oninput} />
+
+ {&props.form.field_message(&props.field_name)}
+
+
+
+ }
+}
diff --git a/app/src/components/form/mod.rs b/app/src/components/form/mod.rs
new file mode 100644
index 0000000..dc112e3
--- /dev/null
+++ b/app/src/components/form/mod.rs
@@ -0,0 +1,4 @@
+pub mod checkbox;
+pub mod field;
+pub mod select;
+pub mod submit;
diff --git a/app/src/components/form/select.rs b/app/src/components/form/select.rs
new file mode 100644
index 0000000..1254214
--- /dev/null
+++ b/app/src/components/form/select.rs
@@ -0,0 +1,46 @@
+use yew::{
+ function_component, html, virtual_dom::AttrValue, Callback, Children, InputEvent, Properties,
+};
+use yew_form::{Form, Model};
+
+#[derive(Properties, PartialEq)]
+pub struct Props {
+ pub label: AttrValue,
+ pub field_name: String,
+ pub form: Form,
+ #[prop_or(false)]
+ pub required: bool,
+ #[prop_or_else(Callback::noop)]
+ pub oninput: Callback,
+ pub children: Children,
+}
+
+#[function_component(Select)]
+pub fn select(props: &Props) -> Html {
+ html! {
+
+
+
+
+ form={&props.form}
+ class="form-control"
+ class_invalid="is-invalid has-error"
+ class_valid="has-success"
+ field_name={props.field_name.clone()}
+ oninput={&props.oninput} >
+ {for props.children.iter()}
+ >
+
+ {&props.form.field_message(&props.field_name)}
+
+
+
+ }
+}
diff --git a/app/src/components/form/submit.rs b/app/src/components/form/submit.rs
new file mode 100644
index 0000000..622917f
--- /dev/null
+++ b/app/src/components/form/submit.rs
@@ -0,0 +1,24 @@
+use web_sys::MouseEvent;
+use yew::{function_component, html, Callback, Properties};
+
+#[derive(Properties, PartialEq)]
+pub struct Props {
+ pub disabled: bool,
+ pub onclick: Callback,
+}
+
+#[function_component(Submit)]
+pub fn submit(props: &Props) -> Html {
+ html! {
+
+
+
+ }
+}
diff --git a/app/src/components/mod.rs b/app/src/components/mod.rs
index f78dcf9..0fe1c13 100644
--- a/app/src/components/mod.rs
+++ b/app/src/components/mod.rs
@@ -4,8 +4,11 @@ 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 form;
pub mod group_details;
pub mod group_table;
pub mod login;
@@ -17,4 +20,5 @@ pub mod router;
pub mod select;
pub mod user_details;
pub mod user_details_form;
+pub mod user_schema_table;
pub mod user_table;
diff --git a/app/src/components/router.rs b/app/src/components/router.rs
index 3b03b61..09e7782 100644
--- a/app/src/components/router.rs
+++ b/app/src/components/router.rs
@@ -22,6 +22,10 @@ pub enum AppRoute {
ListGroups,
#[at("/group/:group_id")]
GroupDetails { group_id: i64 },
+ #[at("/user-attributes")]
+ ListUserSchema,
+ #[at("/user-attributes/create")]
+ CreateUserAttribute,
#[at("/")]
Index,
}
diff --git a/app/src/components/user_schema_table.rs b/app/src/components/user_schema_table.rs
new file mode 100644
index 0000000..45e4446
--- /dev/null
+++ b/app/src/components/user_schema_table.rs
@@ -0,0 +1,198 @@
+use crate::{
+ components::{
+ delete_user_attribute::DeleteUserAttribute,
+ router::{AppRoute, Link},
+ },
+ convert_attribute_type,
+ infra::{
+ common_component::{CommonComponent, CommonComponentParts},
+ schema::AttributeType,
+ },
+};
+use anyhow::{anyhow, Error, Result};
+use gloo_console::log;
+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;
+
+convert_attribute_type!(get_user_attributes_schema::AttributeType);
+
+#[derive(yew::Properties, Clone, PartialEq, Eq)]
+pub struct Props {
+ pub hardcoded: bool,
+}
+
+pub struct UserSchemaTable {
+ common: CommonComponentParts,
+ attributes: Option>,
+}
+
+pub enum Msg {
+ ListAttributesResponse(Result),
+ OnAttributeDeleted(String),
+ OnError(Error),
+}
+
+impl CommonComponent for UserSchemaTable {
+ 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) => {
+ match self.attributes {
+ None => {
+ log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
+ Err(anyhow!("invalid state"))
+ }
+ 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 UserSchemaTable {
+ type Message = Msg;
+ type Properties = Props;
+
+ fn create(ctx: &Context) -> Self {
+ let mut table = UserSchemaTable {
+ 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 UserSchemaTable {
+ fn view_attributes(&self, ctx: &Context) -> Html {
+ let hardcoded = ctx.props().hardcoded;
+ let make_table = |attributes: &Vec| {
+ html! {
+
+
{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}
+
+
+
+ {"Attribute name"} |
+ {"Type"} |
+ {"Editable"} |
+ {"Visible"} |
+ {if hardcoded {html!{}} else {html!{{"Delete"} | }}}
+
+
+
+ {attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::>()}
+
+
+
+ }
+ };
+ match &self.attributes {
+ None => html! {{"Loading..."}},
+ Some(attributes) => {
+ let mut attributes = attributes.clone();
+ attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
+ make_table(&attributes)
+ }
+ }
+ }
+
+ fn view_attribute(&self, ctx: &Context, attribute: &Attribute) -> Html {
+ let link = ctx.link();
+ let attribute_type = AttributeType::from(attribute.attribute_type.clone());
+ let checkmark = html! {
+
+ };
+ let hardcoded = ctx.props().hardcoded;
+ 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 hardcoded {
+ html!{}
+ } else {
+ html!{
+
+
+ |
+ }
+ }
+ }
+
+ }
+ }
+
+ fn view_errors(&self) -> Html {
+ match &self.common.error {
+ None => html! {},
+ Some(e) => html! {{"Error: "}{e.to_string()}
},
+ }
+ }
+}
+
+#[function_component(ListUserSchema)]
+pub fn list_user_schema() -> Html {
+ html! {
+
+
+
+
+
+ {"Create an attribute"}
+
+
+ }
+}
diff --git a/app/src/infra/mod.rs b/app/src/infra/mod.rs
index 2e58c62..663ee08 100644
--- a/app/src/infra/mod.rs
+++ b/app/src/infra/mod.rs
@@ -3,3 +3,4 @@ pub mod common_component;
pub mod cookies;
pub mod graphql;
pub mod modal;
+pub mod schema;
diff --git a/app/src/infra/schema.rs b/app/src/infra/schema.rs
new file mode 100644
index 0000000..3ef8db2
--- /dev/null
+++ b/app/src/infra/schema.rs
@@ -0,0 +1,59 @@
+use anyhow::Result;
+use std::{fmt::Display, str::FromStr};
+
+#[derive(Debug)]
+pub enum AttributeType {
+ String,
+ Integer,
+ DateTime,
+ Jpeg,
+}
+
+impl Display for AttributeType {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{:?}", self)
+ }
+}
+
+impl FromStr for AttributeType {
+ type Err = ();
+ fn from_str(value: &str) -> Result {
+ match value {
+ "String" => Ok(AttributeType::String),
+ "Integer" => Ok(AttributeType::Integer),
+ "DateTime" => Ok(AttributeType::DateTime),
+ "Jpeg" => Ok(AttributeType::Jpeg),
+ _ => Err(()),
+ }
+ }
+}
+
+// Macro to generate traits for converting between AttributeType and the
+// graphql generated equivalents.
+#[macro_export]
+macro_rules! convert_attribute_type {
+ ($source_type:ty) => {
+ impl From<$source_type> for AttributeType {
+ fn from(value: $source_type) -> Self {
+ match value {
+ <$source_type>::STRING => AttributeType::String,
+ <$source_type>::INTEGER => AttributeType::Integer,
+ <$source_type>::DATE_TIME => AttributeType::DateTime,
+ <$source_type>::JPEG_PHOTO => AttributeType::Jpeg,
+ _ => panic!("Unknown attribute type"),
+ }
+ }
+ }
+
+ impl From for $source_type {
+ fn from(value: AttributeType) -> Self {
+ match value {
+ AttributeType::String => <$source_type>::STRING,
+ AttributeType::Integer => <$source_type>::INTEGER,
+ AttributeType::DateTime => <$source_type>::DATE_TIME,
+ AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
+ }
+ }
+ }
+ };
+}