app: Add support for user-created attributes

Note: This PR doesn't handle errors around Jpeg files very well.

Co-authored-by: Bojidar Marinov <bojidar.marinov.bg@gmail.com>
Co-authored-by: Austin Alvarado <pixelrazor@gmail.com>
This commit is contained in:
Austin Alvarado
2024-02-09 05:31:46 +00:00
committed by nitnelave
parent 1f3f73585b
commit dcba3d17dc
16 changed files with 1094 additions and 373 deletions

View File

@@ -37,12 +37,16 @@ version = "0.3"
features = [ features = [
"Document", "Document",
"Element", "Element",
"Event",
"FileReader", "FileReader",
"FormData",
"HtmlDocument", "HtmlDocument",
"HtmlFormElement",
"HtmlInputElement", "HtmlInputElement",
"HtmlOptionElement", "HtmlOptionElement",
"HtmlOptionsCollection", "HtmlOptionsCollection",
"HtmlSelectElement", "HtmlSelectElement",
"SubmitEvent",
"console", "console",
] ]

View File

@@ -8,6 +8,7 @@ query GetUserAttributesSchema {
isVisible isVisible
isEditable isEditable
isHardcoded isHardcoded
isReadonly
} }
} }
} }

View File

@@ -2,15 +2,30 @@ query GetUserDetails($id: String!) {
user(userId: $id) { user(userId: $id) {
id id
email email
displayName
firstName
lastName
avatar avatar
displayName
creationDate creationDate
uuid uuid
groups { groups {
id id
displayName displayName
} }
attributes {
name
value
}
}
schema {
userSchema {
attributes {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
} }
} }

View File

@@ -6,6 +6,7 @@ use yew::{function_component, html, virtual_dom::AttrValue, Properties};
#[graphql( #[graphql(
schema_path = "../schema.graphql", schema_path = "../schema.graphql",
query_path = "queries/get_user_details.graphql", query_path = "queries/get_user_details.graphql",
variables_derives = "Clone,PartialEq,Eq",
response_derives = "Debug, Hash, PartialEq, Eq, Clone", response_derives = "Debug, Hash, PartialEq, Eq, Clone",
custom_scalars_module = "crate::infra::graphql" custom_scalars_module = "crate::infra::graphql"
)] )]

View File

@@ -1,22 +1,45 @@
use crate::{ use crate::{
components::{ components::{
form::{field::Field, submit::Submit}, form::{
attribute_input::{ListAttributeInput, SingleAttributeInput},
field::Field,
submit::Submit,
},
router::AppRoute, router::AppRoute,
}, },
convert_attribute_type,
infra::{ infra::{
api::HostService, api::HostService,
common_component::{CommonComponent, CommonComponentParts}, common_component::{CommonComponent, CommonComponentParts},
schema::AttributeType,
}, },
}; };
use anyhow::{bail, Result}; use anyhow::{anyhow, ensure, Result};
use gloo_console::log; use gloo_console::log;
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use lldap_auth::{opaque, registration}; use lldap_auth::{opaque, registration};
use validator::validate_email;
use validator_derive::Validate; use validator_derive::Validate;
use web_sys::{FormData, HtmlFormElement};
use yew::prelude::*; use yew::prelude::*;
use yew_form_derive::Model; use yew_form_derive::Model;
use yew_router::{prelude::History, scope_ext::RouterScopeExt}; use yew_router::{prelude::History, scope_ext::RouterScopeExt};
#[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(GraphQLQuery)] #[derive(GraphQLQuery)]
#[graphql( #[graphql(
schema_path = "../schema.graphql", schema_path = "../schema.graphql",
@@ -29,17 +52,14 @@ pub struct CreateUser;
pub struct CreateUserForm { pub struct CreateUserForm {
common: CommonComponentParts<Self>, common: CommonComponentParts<Self>,
form: yew_form::Form<CreateUserModel>, form: yew_form::Form<CreateUserModel>,
attributes_schema: Option<Vec<Attribute>>,
form_ref: NodeRef,
} }
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)] #[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
pub struct CreateUserModel { pub struct CreateUserModel {
#[validate(length(min = 1, message = "Username is required"))] #[validate(length(min = 1, message = "Username is required"))]
username: String, username: String,
#[validate(email(message = "A valid email is required"))]
email: String,
display_name: String,
first_name: String,
last_name: String,
#[validate(custom( #[validate(custom(
function = "empty_or_long", function = "empty_or_long",
message = "Password should be longer than 8 characters (or left empty)" message = "Password should be longer than 8 characters (or left empty)"
@@ -59,6 +79,7 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
pub enum Msg { pub enum Msg {
Update, Update,
ListAttributesResponse(Result<ResponseData>),
SubmitForm, SubmitForm,
CreateUserResponse(Result<create_user::ResponseData>), CreateUserResponse(Result<create_user::ResponseData>),
SuccessfulCreation, SuccessfulCreation,
@@ -79,21 +100,56 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
) -> Result<bool> { ) -> Result<bool> {
match msg { match msg {
Msg::Update => Ok(true), Msg::Update => Ok(true),
Msg::ListAttributesResponse(schema) => {
self.attributes_schema =
Some(schema?.schema.user_schema.attributes.into_iter().collect());
Ok(true)
}
Msg::SubmitForm => { Msg::SubmitForm => {
if !self.form.validate() { ensure!(self.form.validate(), "Check the form for errors");
bail!("Check the form for errors");
let form = self.form_ref.cast::<HtmlFormElement>().unwrap();
let form_data = FormData::new_with_form(&form)
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
let all_values = get_values_from_form_data(
self.attributes_schema
.iter()
.flatten()
.filter(|attr| !attr.is_readonly)
.collect(),
&form_data,
)?;
{
let email_values = &all_values
.iter()
.find(|(name, _)| name == "mail")
.ok_or_else(|| anyhow!("Email is required"))?
.1;
ensure!(email_values.len() == 1, "Email is required");
ensure!(validate_email(&email_values[0]), "Email is not valid");
} }
let attributes = if all_values.is_empty() {
None
} else {
Some(
all_values
.into_iter()
.filter(|(_, value)| !value.is_empty())
.map(|(name, value)| create_user::AttributeValueInput { name, value })
.collect(),
)
};
let model = self.form.model(); let model = self.form.model();
let to_option = |s: String| if s.is_empty() { None } else { Some(s) };
let req = create_user::Variables { let req = create_user::Variables {
user: create_user::CreateUserInput { user: create_user::CreateUserInput {
id: model.username, id: model.username,
email: Some(model.email), email: None,
displayName: to_option(model.display_name), displayName: None,
firstName: to_option(model.first_name), firstName: None,
lastName: to_option(model.last_name), lastName: None,
avatar: None, avatar: None,
attributes: None, attributes,
}, },
}; };
self.common.call_graphql::<CreateUser, _>( self.common.call_graphql::<CreateUser, _>(
@@ -177,11 +233,20 @@ impl Component for CreateUserForm {
type Message = Msg; type Message = Msg;
type Properties = (); type Properties = ();
fn create(_: &Context<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
Self { let mut component = Self {
common: CommonComponentParts::<Self>::create(), common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()), form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
} attributes_schema: None,
form_ref: NodeRef::default(),
};
component.common.call_graphql::<GetUserAttributesSchema, _>(
ctx,
get_user_attributes_schema::Variables {},
Msg::ListAttributesResponse,
"Error trying to fetch user schema",
);
component
} }
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
@@ -192,38 +257,22 @@ impl Component for CreateUserForm {
let link = &ctx.link(); let link = &ctx.link();
html! { html! {
<div class="row justify-content-center"> <div class="row justify-content-center">
<form class="form py-3" style="max-width: 636px"> <form class="form py-3"
ref={self.form_ref.clone()}>
<Field<CreateUserModel> <Field<CreateUserModel>
form={&self.form} form={&self.form}
required=true required=true
label="User name" label="User name"
field_name="username" field_name="username"
oninput={link.callback(|_| Msg::Update)} /> oninput={link.callback(|_| Msg::Update)} />
<Field<CreateUserModel> {
form={&self.form} self.attributes_schema
required=true .iter()
label="Email" .flatten()
field_name="email" .filter(|a| !a.is_readonly)
input_type="email" .map(get_custom_attribute_input)
oninput={link.callback(|_| Msg::Update)} /> .collect::<Vec<_>>()
<Field<CreateUserModel> }
form={&self.form}
label="Display name"
field_name="display_name"
autocomplete="name"
oninput={link.callback(|_| Msg::Update)} />
<Field<CreateUserModel>
form={&self.form}
label="First name"
field_name="first_name"
autocomplete="given-name"
oninput={link.callback(|_| Msg::Update)} />
<Field<CreateUserModel>
form={&self.form}
label="Last name"
field_name="last_name"
autocomplete="family-name"
oninput={link.callback(|_| Msg::Update)} />
<Field<CreateUserModel> <Field<CreateUserModel>
form={&self.form} form={&self.form}
label="Password" label="Password"
@@ -255,3 +304,46 @@ impl Component for CreateUserForm {
} }
} }
} }
pub fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
if attribute_schema.is_list {
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
/>
}
}
}
type AttributeValue = (String, Vec<String>);
fn get_values_from_form_data(
schema: Vec<&Attribute>,
form: &FormData,
) -> Result<Vec<AttributeValue>> {
schema
.into_iter()
.map(|attr| -> Result<AttributeValue> {
let val = form
.get_all(attr.name.as_str())
.iter()
.map(|js_val| js_val.as_string().unwrap_or_default())
.filter(|val| !val.is_empty())
.collect::<Vec<String>>();
ensure!(
val.len() <= 1 || attr.is_list,
"Multiple values supplied for non-list attribute {}",
attr.name
);
Ok((attr.name.clone(), val))
})
.collect()
}

View File

@@ -0,0 +1,190 @@
use crate::{
components::form::{date_input::DateTimeInput, file_input::JpegFileInput},
infra::{schema::AttributeType, tooltip::Tooltip},
};
use web_sys::Element;
use yew::{
function_component, html, use_effect_with_deps, use_node_ref, virtual_dom::AttrValue,
Component, Context, Html, Properties,
};
#[derive(Properties, PartialEq)]
struct AttributeInputProps {
name: AttrValue,
attribute_type: AttributeType,
#[prop_or(None)]
value: Option<String>,
}
#[function_component(AttributeInput)]
fn attribute_input(props: &AttributeInputProps) -> Html {
let input_type = match props.attribute_type {
AttributeType::String => "text",
AttributeType::Integer => "number",
AttributeType::DateTime => {
return html! {
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
}
}
AttributeType::Jpeg => {
return html! {
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
}
}
};
html! {
<input
type={input_type}
name={props.name.clone()}
class="form-control"
value={props.value.clone()} />
}
}
#[derive(Properties, PartialEq)]
struct AttributeLabelProps {
pub name: String,
}
#[function_component(AttributeLabel)]
fn attribute_label(props: &AttributeLabelProps) -> Html {
let tooltip_ref = use_node_ref();
use_effect_with_deps(
move |tooltip_ref| {
Tooltip::new(
tooltip_ref
.cast::<Element>()
.expect("Tooltip element should exist"),
);
|| {}
},
tooltip_ref.clone(),
);
html! {
<label for={props.name.clone()}
class="form-label col-4 col-form-label"
>
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}{":"}
<button
class="btn btn-sm btn-link"
type="button"
data-bs-placement="right"
title={props.name.clone()}
ref={tooltip_ref}>
<i class="bi bi-info-circle" aria-label="Info" />
</button>
</label>
}
}
#[derive(Properties, PartialEq)]
pub struct SingleAttributeInputProps {
pub name: String,
pub attribute_type: AttributeType,
#[prop_or(None)]
pub value: Option<String>,
}
#[function_component(SingleAttributeInput)]
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
html! {
<div class="row mb-3">
<AttributeLabel name={props.name.clone()} />
<div class="col-8">
<AttributeInput
attribute_type={props.attribute_type.clone()}
name={props.name.clone()}
value={props.value.clone()} />
</div>
</div>
}
}
#[derive(Properties, PartialEq)]
pub struct ListAttributeInputProps {
pub name: String,
pub attribute_type: AttributeType,
#[prop_or(vec!())]
pub values: Vec<String>,
}
pub enum ListAttributeInputMsg {
Remove(usize),
Append,
}
pub struct ListAttributeInput {
indices: Vec<usize>,
next_index: usize,
values: Vec<String>,
}
impl Component for ListAttributeInput {
type Message = ListAttributeInputMsg;
type Properties = ListAttributeInputProps;
fn create(ctx: &Context<Self>) -> Self {
let values = ctx.props().values.clone();
Self {
indices: (0..values.len()).collect(),
next_index: values.len(),
values,
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
ListAttributeInputMsg::Remove(removed) => {
self.indices.retain_mut(|x| *x != removed);
}
ListAttributeInputMsg::Append => {
self.indices.push(self.next_index);
self.next_index += 1;
}
};
true
}
fn changed(&mut self, ctx: &Context<Self>) -> bool {
if ctx.props().values != self.values {
self.values.clone_from(&ctx.props().values);
self.indices = (0..self.values.len()).collect();
self.next_index = self.values.len();
}
true
}
fn view(&self, ctx: &Context<Self>) -> Html {
let props = &ctx.props();
let link = &ctx.link();
html! {
<div class="row mb-3">
<AttributeLabel name={props.name.clone()} />
<div class="col-8">
{self.indices.iter().map(|&i| html! {
<div class="input-group mb-2" key={i}>
<AttributeInput
attribute_type={props.attribute_type.clone()}
name={props.name.clone()}
value={props.values.get(i).cloned().unwrap_or_default()} />
<button
class="btn btn-danger"
type="button"
onclick={link.callback(move |_| ListAttributeInputMsg::Remove(i))}>
<i class="bi-x-circle-fill" aria-label="Remove value" />
</button>
</div>
}).collect::<Html>()}
<button
class="btn btn-secondary"
type="button"
onclick={link.callback(|_| ListAttributeInputMsg::Append)}>
<i class="bi-plus-circle me-2"></i>
{"Add value"}
</button>
</div>
</div>
}
}
}

View File

@@ -0,0 +1,49 @@
use std::str::FromStr;
use chrono::{DateTime, NaiveDateTime, Utc};
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::{function_component, html, use_state, virtual_dom::AttrValue, Event, Properties};
#[derive(Properties, PartialEq)]
pub struct DateTimeInputProps {
pub name: AttrValue,
pub value: Option<String>,
}
#[function_component(DateTimeInput)]
pub fn date_time_input(props: &DateTimeInputProps) -> Html {
let value = use_state(|| {
props
.value
.as_ref()
.and_then(|x| DateTime::<Utc>::from_str(x).ok())
});
html! {
<div class="input-group">
<input
type="hidden"
name={props.name.clone()}
value={value.as_ref().map(|v: &DateTime<Utc>| v.to_rfc3339())} />
<input
type="datetime-local"
step="1"
class="form-control"
value={value.as_ref().map(|v: &DateTime<Utc>| v.naive_utc().to_string())}
onchange={move |e: Event| {
let string_val =
e.target()
.expect("Event should have target")
.unchecked_into::<HtmlInputElement>()
.value();
value.set(
NaiveDateTime::from_str(&string_val)
.ok()
.map(|x| DateTime::from_utc(x, Utc))
)
}} />
<span class="input-group-text">{"UTC"}</span>
</div>
}
}

View File

@@ -0,0 +1,238 @@
use std::{fmt::Display, str::FromStr};
use anyhow::{bail, Error, Ok, Result};
use gloo_file::{
callbacks::{read_as_bytes, FileReader},
File,
};
use web_sys::{FileList, HtmlInputElement, InputEvent};
use yew::Properties;
use yew::{prelude::*, virtual_dom::AttrValue};
#[derive(Default)]
struct JsFile {
file: Option<File>,
contents: Option<Vec<u8>>,
}
impl Display for JsFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
self.file.as_ref().map(File::name).unwrap_or_default()
)
}
}
impl FromStr for JsFile {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
if s.is_empty() {
Ok(JsFile::default())
} else {
bail!("Building file from non-empty string")
}
}
}
fn to_base64(file: &JsFile) -> Result<String> {
match file {
JsFile {
file: None,
contents: None,
} => Ok(String::new()),
JsFile {
file: Some(_),
contents: None,
} => bail!("Image file hasn't finished loading, try again"),
JsFile {
file: Some(_),
contents: Some(data),
} => {
if !is_valid_jpeg(data.as_slice()) {
bail!("Chosen image is not a valid JPEG");
}
Ok(base64::encode(data))
}
JsFile {
file: None,
contents: Some(data),
} => Ok(base64::encode(data)),
}
}
/// A [yew::Component] to display the user details, with a form allowing to edit them.
pub struct JpegFileInput {
// None means that the avatar hasn't changed.
avatar: Option<JsFile>,
reader: Option<FileReader>,
}
pub enum Msg {
Update,
/// A new file was selected.
FileSelected(File),
/// The "Clear" button for the avatar was clicked.
ClearClicked,
/// A picked file finished loading.
FileLoaded(String, Result<Vec<u8>>),
}
#[derive(Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub name: AttrValue,
pub value: Option<String>,
}
impl Component for JpegFileInput {
type Message = Msg;
type Properties = Props;
fn create(ctx: &Context<Self>) -> Self {
Self {
avatar: Some(JsFile {
file: None,
contents: ctx
.props()
.value
.as_ref()
.and_then(|x| base64::decode(x).ok()),
}),
reader: None,
}
}
fn changed(&mut self, ctx: &Context<Self>) -> bool {
self.avatar = Some(JsFile {
file: None,
contents: ctx
.props()
.value
.as_ref()
.and_then(|x| base64::decode(x).ok()),
});
self.reader = None;
true
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Update => true,
Msg::FileSelected(new_avatar) => {
if self
.avatar
.as_ref()
.and_then(|f| f.file.as_ref().map(|f| f.name()))
!= Some(new_avatar.name())
{
let file_name = new_avatar.name();
let link = ctx.link().clone();
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
link.send_message(Msg::FileLoaded(
file_name,
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
))
}));
self.avatar = Some(JsFile {
file: Some(new_avatar),
contents: None,
});
}
true
}
Msg::ClearClicked => {
self.avatar = Some(JsFile::default());
true
}
Msg::FileLoaded(file_name, data) => {
if let Some(avatar) = &mut self.avatar {
if let Some(file) = &avatar.file {
if file.name() == file_name {
if let Result::Ok(data) = data {
if !is_valid_jpeg(data.as_slice()) {
// Clear the selection.
self.avatar = Some(JsFile::default());
// TODO: bail!("Chosen image is not a valid JPEG");
} else {
avatar.contents = Some(data);
return true;
}
}
}
}
}
self.reader = None;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
let avatar_string = match &self.avatar {
Some(avatar) => {
let avatar_base64 = to_base64(avatar);
avatar_base64.as_deref().unwrap_or("").to_owned()
}
None => String::new(),
};
html! {
<div class="row align-items-center">
<div class="col-5">
<input type="hidden" name={ctx.props().name.clone()} value={avatar_string.clone()} />
<input
class="form-control"
id="avatarInput"
type="file"
accept="image/jpeg"
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
Self::upload_files(input.files())
})} />
</div>
<div class="col-3">
<button
class="btn btn-secondary col-auto"
id="avatarClear"
type="button"
onclick={link.callback(|_| {Msg::ClearClicked})}>
{"Clear"}
</button>
</div>
<div class="col-4">
{
if !avatar_string.is_empty() {
html!{
<img
id="avatarDisplay"
src={format!("data:image/jpeg;base64, {}", avatar_string)}
style="max-height:128px;max-width:128px;height:auto;width:auto;"
alt="Avatar" />
}
} else { html! {} }
}
</div>
</div>
}
}
}
impl JpegFileInput {
fn upload_files(files: Option<FileList>) -> Msg {
match files {
Some(files) if files.length() > 0 => {
Msg::FileSelected(File::from(files.item(0).unwrap()))
}
Some(_) | None => Msg::Update,
}
}
}
fn is_valid_jpeg(bytes: &[u8]) -> bool {
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
.decode()
.is_ok()
}

View File

@@ -1,5 +1,8 @@
pub mod attribute_input;
pub mod checkbox; pub mod checkbox;
pub mod date_input;
pub mod field; pub mod field;
pub mod file_input;
pub mod select; pub mod select;
pub mod static_value; pub mod static_value;
pub mod submit; pub mod submit;

View File

@@ -5,6 +5,7 @@ use crate::{
router::{AppRoute, Link}, router::{AppRoute, Link},
user_details_form::UserDetailsForm, user_details_form::UserDetailsForm,
}, },
convert_attribute_type,
infra::common_component::{CommonComponent, CommonComponentParts}, infra::common_component::{CommonComponent, CommonComponentParts},
}; };
use anyhow::{bail, Error, Result}; use anyhow::{bail, Error, Result};
@@ -22,12 +23,23 @@ pub struct GetUserDetails;
pub type User = get_user_details::GetUserDetailsUser; pub type User = get_user_details::GetUserDetailsUser;
pub type Group = get_user_details::GetUserDetailsUserGroups; pub type Group = get_user_details::GetUserDetailsUserGroups;
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
pub type AttributeType = get_user_details::AttributeType;
convert_attribute_type!(AttributeType);
pub struct UserDetails { pub struct UserDetails {
common: CommonComponentParts<Self>, common: CommonComponentParts<Self>,
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't /// The user info. If none, the error is in `error`. If `error` is None, then we haven't
/// received the server response yet. /// received the server response yet.
user: Option<User>, user_and_schema: Option<(User, Vec<AttributeSchema>)>,
}
impl UserDetails {
fn mut_groups(&mut self) -> &mut Vec<Group> {
&mut self.user_and_schema.as_mut().unwrap().0.groups
}
} }
/// State machine describing the possible transitions of the component state. /// State machine describing the possible transitions of the component state.
@@ -50,22 +62,20 @@ impl CommonComponent<UserDetails> for UserDetails {
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg { match msg {
Msg::UserDetailsResponse(response) => match response { Msg::UserDetailsResponse(response) => match response {
Ok(user) => self.user = Some(user.user), Ok(user) => {
self.user_and_schema = Some((user.user, user.schema.user_schema.attributes))
}
Err(e) => { Err(e) => {
self.user = None; self.user_and_schema = None;
bail!("Error getting user details: {}", e); bail!("Error getting user details: {}", e);
} }
}, },
Msg::OnError(e) => return Err(e), Msg::OnError(e) => return Err(e),
Msg::OnUserAddedToGroup(group) => { Msg::OnUserAddedToGroup(group) => {
self.user.as_mut().unwrap().groups.push(group); self.mut_groups().push(group);
} }
Msg::OnUserRemovedFromGroup((_, group_id)) => { Msg::OnUserRemovedFromGroup((_, group_id)) => {
self.user self.mut_groups().retain(|g| g.id != group_id);
.as_mut()
.unwrap()
.groups
.retain(|g| g.id != group_id);
} }
} }
Ok(true) Ok(true)
@@ -178,7 +188,7 @@ impl Component for UserDetails {
fn create(ctx: &Context<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
let mut table = Self { let mut table = Self {
common: CommonComponentParts::<Self>::create(), common: CommonComponentParts::<Self>::create(),
user: None, user_and_schema: None,
}; };
table.get_user_details(ctx); table.get_user_details(ctx);
table table
@@ -189,10 +199,8 @@ impl Component for UserDetails {
} }
fn view(&self, ctx: &Context<Self>) -> Html { fn view(&self, ctx: &Context<Self>) -> Html {
match (&self.user, &self.common.error) { match (&self.user_and_schema, &self.common.error) {
(None, None) => html! {{"Loading..."}}, (Some((u, schema)), error) => {
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
(Some(u), error) => {
html! { html! {
<> <>
<h3>{u.id.to_string()}</h3> <h3>{u.id.to_string()}</h3>
@@ -207,13 +215,19 @@ impl Component for UserDetails {
<div> <div>
<h5 class="row m-3 fw-bold">{"User details"}</h5> <h5 class="row m-3 fw-bold">{"User details"}</h5>
</div> </div>
<UserDetailsForm user={u.clone()} /> <UserDetailsForm
user={u.clone()}
user_attributes_schema={schema.clone()}
is_admin={ctx.props().is_admin}
/>
{self.view_group_memberships(ctx, u)} {self.view_group_memberships(ctx, u)}
{self.view_add_group_button(ctx, u)} {self.view_add_group_button(ctx, u)}
{self.view_messages(error)} {self.view_messages(error)}
</> </>
} }
} }
(None, None) => html! {{"Loading..."}},
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
} }
} }
} }

View File

@@ -1,59 +1,32 @@
use std::{fmt::Display, str::FromStr};
use crate::{ use crate::{
components::{ components::{
form::{field::Field, static_value::StaticValue, submit::Submit}, form::{
user_details::User, attribute_input::{ListAttributeInput, SingleAttributeInput},
static_value::StaticValue,
submit::Submit,
},
user_details::{Attribute, AttributeSchema, User},
},
infra::{
common_component::{CommonComponent, CommonComponentParts},
schema::AttributeType,
}, },
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Error, Result};
use gloo_file::{
callbacks::{read_as_bytes, FileReader},
File,
}; };
use anyhow::{anyhow, bail, ensure, Ok, Result};
use gloo_console::log;
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use validator::HasLen;
use validator_derive::Validate; use validator_derive::Validate;
use web_sys::{FileList, HtmlInputElement, InputEvent}; use web_sys::{FormData, HtmlFormElement};
use yew::prelude::*; use yew::prelude::*;
use yew_form_derive::Model; use yew_form_derive::Model;
#[derive(Default)]
struct JsFile {
file: Option<File>,
contents: Option<Vec<u8>>,
}
impl Display for JsFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
self.file.as_ref().map(File::name).unwrap_or_default()
)
}
}
impl FromStr for JsFile {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
if s.is_empty() {
Ok(JsFile::default())
} else {
bail!("Building file from non-empty string")
}
}
}
/// The fields of the form, with the editable details and the constraints. /// The fields of the form, with the editable details and the constraints.
#[derive(Model, Validate, PartialEq, Eq, Clone)] #[derive(Model, Validate, PartialEq, Eq, Clone)]
pub struct UserModel { pub struct UserModel {
#[validate(email)] #[validate(email)]
email: String, email: String,
display_name: String, display_name: String,
first_name: String,
last_name: String,
} }
/// The GraphQL query sent to the server to update the user details. /// The GraphQL query sent to the server to update the user details.
@@ -71,25 +44,17 @@ pub struct UpdateUser;
pub struct UserDetailsForm { pub struct UserDetailsForm {
common: CommonComponentParts<Self>, common: CommonComponentParts<Self>,
form: yew_form::Form<UserModel>, form: yew_form::Form<UserModel>,
// None means that the avatar hasn't changed.
avatar: Option<JsFile>,
reader: Option<FileReader>,
/// True if we just successfully updated the user, to display a success message. /// True if we just successfully updated the user, to display a success message.
just_updated: bool, just_updated: bool,
user: User, user: User,
form_ref: NodeRef,
} }
pub enum Msg { pub enum Msg {
/// A form field changed. /// A form field changed.
Update, Update,
/// A new file was selected.
FileSelected(File),
/// The "Submit" button was clicked. /// The "Submit" button was clicked.
SubmitClicked, SubmitClicked,
/// The "Clear" button for the avatar was clicked.
ClearAvatarClicked,
/// A picked file finished loading.
FileLoaded(String, Result<Vec<u8>>),
/// We got the response from the server about our update message. /// We got the response from the server about our update message.
UserUpdated(Result<update_user::ResponseData>), UserUpdated(Result<update_user::ResponseData>),
} }
@@ -98,6 +63,8 @@ pub enum Msg {
pub struct Props { pub struct Props {
/// The current user details. /// The current user details.
pub user: User, pub user: User,
pub user_attributes_schema: Vec<AttributeSchema>,
pub is_admin: bool,
} }
impl CommonComponent<UserDetailsForm> for UserDetailsForm { impl CommonComponent<UserDetailsForm> for UserDetailsForm {
@@ -108,53 +75,8 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
) -> Result<bool> { ) -> Result<bool> {
match msg { match msg {
Msg::Update => Ok(true), Msg::Update => Ok(true),
Msg::FileSelected(new_avatar) => {
if self
.avatar
.as_ref()
.and_then(|f| f.file.as_ref().map(|f| f.name()))
!= Some(new_avatar.name())
{
let file_name = new_avatar.name();
let link = ctx.link().clone();
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
link.send_message(Msg::FileLoaded(
file_name,
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
))
}));
self.avatar = Some(JsFile {
file: Some(new_avatar),
contents: None,
});
}
Ok(true)
}
Msg::SubmitClicked => self.submit_user_update_form(ctx), Msg::SubmitClicked => self.submit_user_update_form(ctx),
Msg::ClearAvatarClicked => {
self.avatar = Some(JsFile::default());
Ok(true)
}
Msg::UserUpdated(response) => self.user_update_finished(response), Msg::UserUpdated(response) => self.user_update_finished(response),
Msg::FileLoaded(file_name, data) => {
if let Some(avatar) = &mut self.avatar {
if let Some(file) = &avatar.file {
if file.name() == file_name {
let data = data?;
if !is_valid_jpeg(data.as_slice()) {
// Clear the selection.
self.avatar = None;
bail!("Chosen image is not a valid JPEG");
} else {
avatar.contents = Some(data);
return Ok(true);
}
}
}
}
self.reader = None;
Ok(false)
}
} }
} }
@@ -171,16 +93,13 @@ impl Component for UserDetailsForm {
let model = UserModel { let model = UserModel {
email: ctx.props().user.email.clone(), email: ctx.props().user.email.clone(),
display_name: ctx.props().user.display_name.clone(), display_name: ctx.props().user.display_name.clone(),
first_name: ctx.props().user.first_name.clone(),
last_name: ctx.props().user.last_name.clone(),
}; };
Self { Self {
common: CommonComponentParts::<Self>::create(), common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::new(model), form: yew_form::Form::new(model),
avatar: None,
just_updated: false, just_updated: false,
reader: None,
user: ctx.props().user.clone(), user: ctx.props().user.clone(),
form_ref: NodeRef::default(),
} }
} }
@@ -192,93 +111,41 @@ impl Component for UserDetailsForm {
fn view(&self, ctx: &Context<Self>) -> Html { fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link(); let link = &ctx.link();
let avatar_string = match &self.avatar { let can_edit =
Some(avatar) => { |a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
let avatar_base64 = to_base64(avatar); let display_field = |a: &AttributeSchema| {
avatar_base64.as_deref().unwrap_or("").to_owned() if can_edit(a) {
get_custom_attribute_input(a, &self.user.attributes)
} else {
get_custom_attribute_static(a, &self.user.attributes)
} }
None => self.user.avatar.as_deref().unwrap_or("").to_owned(),
}; };
html! { html! {
<div class="py-3"> <div class="py-3">
<form class="form"> <form
class="form"
ref={self.form_ref.clone()}>
<StaticValue label="User ID" id="userId"> <StaticValue label="User ID" id="userId">
<i>{&self.user.id}</i> <i>{&self.user.id}</i>
</StaticValue> </StaticValue>
<StaticValue label="Creation date" id="creationDate"> {
{&self.user.creation_date.naive_local().date()} ctx
</StaticValue> .props()
<StaticValue label="UUID" id="uuid"> .user_attributes_schema
{&self.user.uuid} .iter()
</StaticValue> .filter(|a| a.is_hardcoded && a.name != "user_id")
<Field<UserModel> .map(display_field)
form={&self.form} .collect::<Vec<_>>()
required=true }
label="Email" {
field_name="email" ctx
input_type="email" .props()
oninput={link.callback(|_| Msg::Update)} /> .user_attributes_schema
<Field<UserModel> .iter()
form={&self.form} .filter(|a| !a.is_hardcoded)
label="Display name" .map(display_field)
field_name="display_name" .collect::<Vec<_>>()
autocomplete="name" }
oninput={link.callback(|_| Msg::Update)} />
<Field<UserModel>
form={&self.form}
label="First name"
field_name="first_name"
autocomplete="given-name"
oninput={link.callback(|_| Msg::Update)} />
<Field<UserModel>
form={&self.form}
label="Last name"
field_name="last_name"
autocomplete="family-name"
oninput={link.callback(|_| Msg::Update)} />
<div class="form-group row align-items-center mb-3">
<label for="avatar"
class="form-label col-4 col-form-label">
{"Avatar: "}
</label>
<div class="col-8">
<div class="row align-items-center">
<div class="col-5">
<input
class="form-control"
id="avatarInput"
type="file"
accept="image/jpeg"
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
Self::upload_files(input.files())
})} />
</div>
<div class="col-3">
<button
class="btn btn-secondary col-auto"
id="avatarClear"
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::ClearAvatarClicked})}>
{"Clear"}
</button>
</div>
<div class="col-4">
{
if !avatar_string.is_empty() {
html!{
<img
id="avatarDisplay"
src={format!("data:image/jpeg;base64, {}", avatar_string)}
style="max-height:128px;max-width:128px;height:auto;width:auto;"
alt="Avatar" />
}
} else { html! {} }
}
</div>
</div>
</div>
</div>
<Submit <Submit
text="Save changes" text="Save changes"
disabled={self.common.is_task_running()} disabled={self.common.is_task_running()}
@@ -301,19 +168,136 @@ impl Component for UserDetailsForm {
} }
} }
type AttributeValue = (String, Vec<String>);
fn get_values_from_form_data(
schema: Vec<&AttributeSchema>,
form: &FormData,
) -> Result<Vec<AttributeValue>> {
schema
.into_iter()
.map(|attr| -> Result<AttributeValue> {
let val = form
.get_all(attr.name.as_str())
.iter()
.map(|js_val| js_val.as_string().unwrap_or_default())
.filter(|val| !val.is_empty())
.collect::<Vec<String>>();
ensure!(
val.length() <= 1 || attr.is_list,
"Multiple values supplied for non-list attribute {}",
attr.name
);
Ok((attr.name.clone(), val))
})
.collect()
}
fn get_custom_attribute_input(
attribute_schema: &AttributeSchema,
user_attributes: &[Attribute],
) -> Html {
if attribute_schema.is_list {
let values = user_attributes
.iter()
.find(|a| a.name == attribute_schema.name)
.map(|attribute| attribute.value.clone())
.unwrap_or_default();
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
values={values}
/>
}
} else {
let value = user_attributes
.iter()
.find(|a| a.name == attribute_schema.name)
.and_then(|attribute| attribute.value.first().cloned())
.unwrap_or_default();
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
value={value}
/>
}
}
}
fn get_custom_attribute_static(
attribute_schema: &AttributeSchema,
user_attributes: &[Attribute],
) -> Html {
let values = user_attributes
.iter()
.find(|a| a.name == attribute_schema.name)
.map(|attribute| attribute.value.clone())
.unwrap_or_default();
html! {
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
</StaticValue>
}
}
impl UserDetailsForm { impl UserDetailsForm {
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> { fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
if !self.form.validate() { if !self.form.validate() {
bail!("Invalid inputs"); bail!("Invalid inputs");
} }
if let Some(JsFile { // TODO: Handle unloaded files.
file: Some(_), // if let Some(JsFile {
contents: None, // file: Some(_),
}) = &self.avatar // contents: None,
{ // }) = &self.avatar
bail!("Image file hasn't finished loading, try again"); // {
} // bail!("Image file hasn't finished loading, try again");
let base_user = &self.user; // }
let form = self.form_ref.cast::<HtmlFormElement>().unwrap();
let form_data = FormData::new_with_form(&form)
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
let mut all_values = get_values_from_form_data(
ctx.props()
.user_attributes_schema
.iter()
.filter(|attr| (ctx.props().is_admin && !attr.is_readonly) || attr.is_editable)
.collect(),
&form_data,
)?;
let base_attributes = &self.user.attributes;
log!(format!(
"base_attributes: {:#?}\nall_values: {:#?}",
base_attributes, all_values
));
all_values.retain(|(name, val)| {
let name = name.clone();
let base_val = base_attributes
.iter()
.find(|base_val| base_val.name == name);
let new_values = val.clone();
base_val
.map(|v| v.value != new_values)
.unwrap_or(!new_values.is_empty())
});
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
None
} else {
Some(all_values.iter().map(|(name, _)| name.clone()).collect())
};
let insert_attributes: Option<Vec<update_user::AttributeValueInput>> =
if remove_attributes.is_none() {
None
} else {
Some(
all_values
.into_iter()
.filter(|(_, value)| !value.is_empty())
.map(|(name, value)| update_user::AttributeValueInput { name, value })
.collect(),
)
};
let mut user_input = update_user::UpdateUserInput { let mut user_input = update_user::UpdateUserInput {
id: self.user.id.clone(), id: self.user.id.clone(),
email: None, email: None,
@@ -325,23 +309,8 @@ impl UserDetailsForm {
insertAttributes: None, insertAttributes: None,
}; };
let default_user_input = user_input.clone(); let default_user_input = user_input.clone();
let model = self.form.model(); user_input.removeAttributes = remove_attributes;
let email = model.email; user_input.insertAttributes = insert_attributes;
if base_user.email != email {
user_input.email = Some(email);
}
if base_user.display_name != model.display_name {
user_input.displayName = Some(model.display_name);
}
if base_user.first_name != model.first_name {
user_input.firstName = Some(model.first_name);
}
if base_user.last_name != model.last_name {
user_input.lastName = Some(model.last_name);
}
if let Some(avatar) = &self.avatar {
user_input.avatar = Some(to_base64(avatar)?);
}
// Nothing changed. // Nothing changed.
if user_input == default_user_input { if user_input == default_user_input {
return Ok(false); return Ok(false);
@@ -361,52 +330,7 @@ impl UserDetailsForm {
let model = self.form.model(); let model = self.form.model();
self.user.email = model.email; self.user.email = model.email;
self.user.display_name = model.display_name; self.user.display_name = model.display_name;
self.user.first_name = model.first_name;
self.user.last_name = model.last_name;
if let Some(avatar) = &self.avatar {
self.user.avatar = Some(to_base64(avatar)?);
}
self.just_updated = true; self.just_updated = true;
Ok(true) Ok(true)
} }
fn upload_files(files: Option<FileList>) -> Msg {
if let Some(files) = files {
if files.length() > 0 {
Msg::FileSelected(File::from(files.item(0).unwrap()))
} else {
Msg::Update
}
} else {
Msg::Update
}
}
}
fn is_valid_jpeg(bytes: &[u8]) -> bool {
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
.decode()
.is_ok()
}
fn to_base64(file: &JsFile) -> Result<String> {
match file {
JsFile {
file: None,
contents: _,
} => Ok(String::new()),
JsFile {
file: Some(_),
contents: None,
} => bail!("Image file hasn't finished loading, try again"),
JsFile {
file: Some(_),
contents: Some(data),
} => {
if !is_valid_jpeg(data.as_slice()) {
bail!("Chosen image is not a valid JPEG");
}
Ok(base64::encode(data))
}
}
} }

View File

@@ -2,7 +2,7 @@ use crate::infra::api::HostService;
use anyhow::Result; use anyhow::Result;
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use yew::{use_effect, use_state_eq, UseStateHandle}; use yew::{use_effect_with_deps, use_state_eq, UseStateHandle};
// Enum to represent a result that is fetched asynchronously. // Enum to represent a result that is fetched asynchronously.
#[derive(Debug)] #[derive(Debug)]
@@ -31,22 +31,29 @@ pub fn use_graphql_call<QueryType>(
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>> ) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
where where
QueryType: GraphQLQuery + 'static, QueryType: GraphQLQuery + 'static,
<QueryType as graphql_client::GraphQLQuery>::Variables: std::cmp::PartialEq + Clone,
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq, <QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
{ {
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> = let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
use_state_eq(|| LoadableResult::Loading); use_state_eq(|| LoadableResult::Loading);
{ {
let loadable_result = loadable_result.clone(); let loadable_result = loadable_result.clone();
use_effect(move || { use_effect_with_deps(
let task = HostService::graphql_query::<QueryType>(variables, "Failed graphql query"); move |variables| {
let task = HostService::graphql_query::<QueryType>(
variables.clone(),
"Failed graphql query",
);
spawn_local(async move { spawn_local(async move {
let response = task.await; let response = task.await;
loadable_result.set(LoadableResult::Loaded(response)); loadable_result.set(LoadableResult::Loaded(response));
}); });
|| () || ()
}) },
variables,
)
} }
loadable_result.clone() loadable_result.clone()
} }

View File

@@ -5,3 +5,4 @@ pub mod functional;
pub mod graphql; pub mod graphql;
pub mod modal; pub mod modal;
pub mod schema; pub mod schema;
pub mod tooltip;

View File

@@ -2,7 +2,7 @@ use anyhow::Result;
use std::{fmt::Display, str::FromStr}; use std::{fmt::Display, str::FromStr};
use validator::ValidationError; use validator::ValidationError;
#[derive(Debug)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttributeType { pub enum AttributeType {
String, String,
Integer, Integer,
@@ -34,25 +34,25 @@ impl FromStr for AttributeType {
#[macro_export] #[macro_export]
macro_rules! convert_attribute_type { macro_rules! convert_attribute_type {
($source_type:ty) => { ($source_type:ty) => {
impl From<$source_type> for AttributeType { impl From<$source_type> for $crate::infra::schema::AttributeType {
fn from(value: $source_type) -> Self { fn from(value: $source_type) -> Self {
match value { match value {
<$source_type>::STRING => AttributeType::String, <$source_type>::STRING => $crate::infra::schema::AttributeType::String,
<$source_type>::INTEGER => AttributeType::Integer, <$source_type>::INTEGER => $crate::infra::schema::AttributeType::Integer,
<$source_type>::DATE_TIME => AttributeType::DateTime, <$source_type>::DATE_TIME => $crate::infra::schema::AttributeType::DateTime,
<$source_type>::JPEG_PHOTO => AttributeType::Jpeg, <$source_type>::JPEG_PHOTO => $crate::infra::schema::AttributeType::Jpeg,
_ => panic!("Unknown attribute type"), _ => panic!("Unknown attribute type"),
} }
} }
} }
impl From<AttributeType> for $source_type { impl From<$crate::infra::schema::AttributeType> for $source_type {
fn from(value: AttributeType) -> Self { fn from(value: $crate::infra::schema::AttributeType) -> Self {
match value { match value {
AttributeType::String => <$source_type>::STRING, $crate::infra::schema::AttributeType::String => <$source_type>::STRING,
AttributeType::Integer => <$source_type>::INTEGER, $crate::infra::schema::AttributeType::Integer => <$source_type>::INTEGER,
AttributeType::DateTime => <$source_type>::DATE_TIME, $crate::infra::schema::AttributeType::DateTime => <$source_type>::DATE_TIME,
AttributeType::Jpeg => <$source_type>::JPEG_PHOTO, $crate::infra::schema::AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
} }
} }
} }

12
app/src/infra/tooltip.rs Normal file
View File

@@ -0,0 +1,12 @@
#![allow(clippy::empty_docs)]
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = bootstrap)]
pub type Tooltip;
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
pub fn new(e: web_sys::Element) -> Tooltip;
}

View File

@@ -7,7 +7,9 @@ use crate::{
ldap::utils::{map_user_field, UserFieldType}, ldap::utils::{map_user_field, UserFieldType},
model::UserColumn, model::UserColumn,
schema::PublicSchema, schema::PublicSchema,
types::{AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, UserId}, types::{
AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, Serialized, UserId,
},
}, },
infra::{ infra::{
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler}, access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
@@ -16,7 +18,7 @@ use crate::{
}; };
use anyhow::Context as AnyhowContext; use anyhow::Context as AnyhowContext;
use chrono::{NaiveDateTime, TimeZone}; use chrono::{NaiveDateTime, TimeZone};
use juniper::{graphql_object, FieldError, FieldResult, GraphQLInputObject}; use juniper::{graphql_object, FieldResult, GraphQLInputObject};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, debug_span, Instrument, Span}; use tracing::{debug, debug_span, Instrument, Span};
@@ -247,15 +249,10 @@ pub struct User<Handler: BackendHandler> {
impl<Handler: BackendHandler> User<Handler> { impl<Handler: BackendHandler> User<Handler> {
pub fn from_user(mut user: DomainUser, schema: Arc<PublicSchema>) -> FieldResult<Self> { pub fn from_user(mut user: DomainUser, schema: Arc<PublicSchema>) -> FieldResult<Self> {
let attributes = std::mem::take(&mut user.attributes); let attributes = AttributeValue::<Handler>::user_attributes_from_schema(&mut user, &schema);
Ok(Self { Ok(Self {
user, user,
attributes: attributes attributes,
.into_iter()
.map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().user_attributes)
})
.collect::<FieldResult<Vec<_>>>()?,
schema, schema,
groups: None, groups: None,
_phantom: std::marker::PhantomData, _phantom: std::marker::PhantomData,
@@ -370,42 +367,36 @@ pub struct Group<Handler: BackendHandler> {
impl<Handler: BackendHandler> Group<Handler> { impl<Handler: BackendHandler> Group<Handler> {
pub fn from_group( pub fn from_group(
group: DomainGroup, mut group: DomainGroup,
schema: Arc<PublicSchema>, schema: Arc<PublicSchema>,
) -> FieldResult<Group<Handler>> { ) -> FieldResult<Group<Handler>> {
let attributes =
AttributeValue::<Handler>::group_attributes_from_schema(&mut group, &schema);
Ok(Self { Ok(Self {
group_id: group.id.0, group_id: group.id.0,
display_name: group.display_name.to_string(), display_name: group.display_name.to_string(),
creation_date: group.creation_date, creation_date: group.creation_date,
uuid: group.uuid.into_string(), uuid: group.uuid.into_string(),
attributes: group attributes,
.attributes
.into_iter()
.map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
})
.collect::<FieldResult<Vec<_>>>()?,
schema, schema,
_phantom: std::marker::PhantomData, _phantom: std::marker::PhantomData,
}) })
} }
pub fn from_group_details( pub fn from_group_details(
group_details: GroupDetails, mut group_details: GroupDetails,
schema: Arc<PublicSchema>, schema: Arc<PublicSchema>,
) -> FieldResult<Group<Handler>> { ) -> FieldResult<Group<Handler>> {
let attributes = AttributeValue::<Handler>::group_details_attributes_from_schema(
&mut group_details,
&schema,
);
Ok(Self { Ok(Self {
group_id: group_details.group_id.0, group_id: group_details.group_id.0,
display_name: group_details.display_name.to_string(), display_name: group_details.display_name.to_string(),
creation_date: group_details.creation_date, creation_date: group_details.creation_date,
uuid: group_details.uuid.into_string(), uuid: group_details.uuid.into_string(),
attributes: group_details attributes,
.attributes
.into_iter()
.map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
})
.collect::<FieldResult<Vec<_>>>()?,
schema, schema,
_phantom: std::marker::PhantomData, _phantom: std::marker::PhantomData,
}) })
@@ -607,6 +598,19 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
} }
} }
impl<Handler: BackendHandler> AttributeValue<Handler> {
fn from_domain(value: DomainAttributeValue, schema: DomainAttributeSchema) -> Self {
Self {
attribute: value,
schema: AttributeSchema::<Handler> {
schema,
_phantom: std::marker::PhantomData,
},
_phantom: std::marker::PhantomData,
}
}
}
impl<Handler: BackendHandler> Clone for AttributeValue<Handler> { impl<Handler: BackendHandler> Clone for AttributeValue<Handler> {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
@@ -661,18 +665,136 @@ pub fn serialize_attribute(
} }
impl<Handler: BackendHandler> AttributeValue<Handler> { impl<Handler: BackendHandler> AttributeValue<Handler> {
fn from_schema(a: DomainAttributeValue, schema: &DomainAttributeList) -> FieldResult<Self> { fn from_schema(a: DomainAttributeValue, schema: &DomainAttributeList) -> Option<Self> {
match schema.get_attribute_schema(&a.name) { schema
Some(s) => Ok(AttributeValue::<Handler> { .get_attribute_schema(&a.name)
attribute: a, .map(|s| AttributeValue::<Handler>::from_domain(a, s.clone()))
schema: AttributeSchema::<Handler> { }
schema: s.clone(),
_phantom: std::marker::PhantomData, fn user_attributes_from_schema(
}, user: &mut DomainUser,
_phantom: std::marker::PhantomData, schema: &PublicSchema,
}), ) -> Vec<AttributeValue<Handler>> {
None => Err(FieldError::from(format!("Unknown attribute {}", &a.name))), let user_attributes = std::mem::take(&mut user.attributes);
} let mut all_attributes = schema
.get_schema()
.user_attributes
.attributes
.iter()
.filter(|a| a.is_hardcoded)
.flat_map(|attribute| {
let value = match attribute.name.as_str() {
"user_id" => Some(Serialized::from(&user.user_id)),
"creation_date" => Some(Serialized::from(&user.creation_date)),
"mail" => Some(Serialized::from(&user.email)),
"uuid" => Some(Serialized::from(&user.uuid)),
"display_name" => user.display_name.as_ref().map(Serialized::from),
"avatar" | "first_name" | "last_name" => None,
_ => panic!("Unexpected hardcoded attribute: {}", attribute.name),
};
value.map(|v| (attribute, v))
})
.map(|(attribute, value)| {
AttributeValue::<Handler>::from_domain(
DomainAttributeValue {
name: attribute.name.clone(),
value,
},
attribute.clone(),
)
})
.collect::<Vec<_>>();
user_attributes
.into_iter()
.flat_map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().user_attributes)
})
.for_each(|value| all_attributes.push(value));
all_attributes
}
fn group_attributes_from_schema(
group: &mut DomainGroup,
schema: &PublicSchema,
) -> Vec<AttributeValue<Handler>> {
let group_attributes = std::mem::take(&mut group.attributes);
let mut all_attributes = schema
.get_schema()
.group_attributes
.attributes
.iter()
.filter(|a| a.is_hardcoded)
.map(|attribute| {
(
attribute,
match attribute.name.as_str() {
"group_id" => Serialized::from(&(group.id.0 as i64)),
"creation_date" => Serialized::from(&group.creation_date),
"uuid" => Serialized::from(&group.uuid),
"display_name" => Serialized::from(&group.display_name),
_ => panic!("Unexpected hardcoded attribute: {}", attribute.name),
},
)
})
.map(|(attribute, value)| {
AttributeValue::<Handler>::from_domain(
DomainAttributeValue {
name: attribute.name.clone(),
value,
},
attribute.clone(),
)
})
.collect::<Vec<_>>();
group_attributes
.into_iter()
.flat_map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
})
.for_each(|value| all_attributes.push(value));
all_attributes
}
fn group_details_attributes_from_schema(
group: &mut GroupDetails,
schema: &PublicSchema,
) -> Vec<AttributeValue<Handler>> {
let group_attributes = std::mem::take(&mut group.attributes);
let mut all_attributes = schema
.get_schema()
.group_attributes
.attributes
.iter()
.filter(|a| a.is_hardcoded)
.map(|attribute| {
(
attribute,
match attribute.name.as_str() {
"group_id" => Serialized::from(&(group.group_id.0 as i64)),
"creation_date" => Serialized::from(&group.creation_date),
"uuid" => Serialized::from(&group.uuid),
"display_name" => Serialized::from(&group.display_name),
_ => panic!("Unexpected hardcoded attribute: {}", attribute.name),
},
)
})
.map(|(attribute, value)| {
AttributeValue::<Handler>::from_domain(
DomainAttributeValue {
name: attribute.name.clone(),
value,
},
attribute.clone(),
)
})
.collect::<Vec<_>>();
group_attributes
.into_iter()
.flat_map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
})
.for_each(|value| all_attributes.push(value));
all_attributes
} }
} }
@@ -827,7 +949,6 @@ mod tests {
let schema = schema(Query::<MockTestBackendHandler>::new()); let schema = schema(Query::<MockTestBackendHandler>::new());
assert_eq!( assert_eq!(
execute(QUERY, None, &schema, &Variables::new(), &context).await,
Ok(( Ok((
graphql_value!( graphql_value!(
{ {
@@ -835,10 +956,26 @@ mod tests {
"id": "bob", "id": "bob",
"email": "bob@bobbers.on", "email": "bob@bobbers.on",
"creationDate": "1970-01-01T00:00:00.042+00:00", "creationDate": "1970-01-01T00:00:00.042+00:00",
"uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
"firstName": "Bob", "firstName": "Bob",
"lastName": "Bobberson", "lastName": "Bobberson",
"uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
"attributes": [{ "attributes": [{
"name": "creation_date",
"value": ["1970-01-01T00:00:00.042+00:00"],
},
{
"name": "mail",
"value": ["bob@bobbers.on"],
},
{
"name": "user_id",
"value": ["bob"],
},
{
"name": "uuid",
"value": ["b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"],
},
{
"name": "first_name", "name": "first_name",
"value": ["Bob"], "value": ["Bob"],
}, },
@@ -852,6 +989,22 @@ mod tests {
"creationDate": "1970-01-01T00:00:00.000000042+00:00", "creationDate": "1970-01-01T00:00:00.000000042+00:00",
"uuid": "a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8", "uuid": "a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
"attributes": [{ "attributes": [{
"name": "creation_date",
"value": ["1970-01-01T00:00:00.000000042+00:00"],
},
{
"name": "display_name",
"value": ["Bobbersons"],
},
{
"name": "group_id",
"value": ["3"],
},
{
"name": "uuid",
"value": ["a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"],
},
{
"name": "club_name", "name": "club_name",
"value": ["Gang of Four"], "value": ["Gang of Four"],
}, },
@@ -862,12 +1015,29 @@ mod tests {
"displayName": "Jefferees", "displayName": "Jefferees",
"creationDate": "1970-01-01T00:00:00.000000012+00:00", "creationDate": "1970-01-01T00:00:00.000000012+00:00",
"uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8", "uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
"attributes": [], "attributes": [{
"name": "creation_date",
"value": ["1970-01-01T00:00:00.000000012+00:00"],
},
{
"name": "display_name",
"value": ["Jefferees"],
},
{
"name": "group_id",
"value": ["7"],
},
{
"name": "uuid",
"value": ["b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"],
},
],
}] }]
} }
}), }),
vec![] vec![]
)) )),
execute(QUERY, None, &schema, &Variables::new(), &context).await
); );
} }