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! { +
    +
    +
    {"Create a user attribute"}
    + + label="Name" + required={true} + form={&self.form} + field_name="attribute_name" + oninput={link.callback(|_| Msg::Update)} /> + + label="Type" + required={true} + form={&self.form} + field_name="attribute_name" + oninput={link.callback(|_| Msg::Update)}> + + + + + > + + label="Multiple values" + form={&self.form} + field_name="is_list" + ontoggle={link.callback(|_| Msg::Update)} /> + + label="Visible to users" + form={&self.form} + field_name="is_visible" + ontoggle={link.callback(|_| Msg::Update)} /> + + label="Editable by users" + form={&self.form} + field_name="is_editable" + ontoggle={link.callback(|_| Msg::Update)} /> + + + { 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! { + + } + } +} 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! { +
    + +
    + + form={&props.form} + field_name={props.field_name.clone()} + ontoggle={props.ontoggle.clone()} /> +
    +
    + } +} 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"}

    + + + + + + + + {if hardcoded {html!{}} else {html!{}}} + + + + {attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::>()} + +
    {"Attribute name"}{"Type"}{"Editable"}{"Visible"}{"Delete"}
    +
    + } + }; + 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, + } + } + } + }; +}