mirror of
https://github.com/nitnelave/lldap.git
synced 2023-04-12 14:25:13 +00:00
app: Add support for modifying an avatar
This commit is contained in:
parent
60c594438c
commit
686bdc0cb1
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -2159,6 +2159,7 @@ name = "lldap_app"
|
|||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"graphql_client 0.10.0",
|
"graphql_client 0.10.0",
|
||||||
"http",
|
"http",
|
||||||
|
@ -6,6 +6,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
base64 = "0.13"
|
||||||
graphql_client = "0.10"
|
graphql_client = "0.10"
|
||||||
http = "0.2"
|
http = "0.2"
|
||||||
jwt = "0.13"
|
jwt = "0.13"
|
||||||
@ -27,6 +28,7 @@ version = "0.3"
|
|||||||
features = [
|
features = [
|
||||||
"Document",
|
"Document",
|
||||||
"Element",
|
"Element",
|
||||||
|
"FileReader",
|
||||||
"HtmlDocument",
|
"HtmlDocument",
|
||||||
"HtmlInputElement",
|
"HtmlInputElement",
|
||||||
"HtmlOptionElement",
|
"HtmlOptionElement",
|
||||||
|
@ -5,6 +5,7 @@ query GetUserDetails($id: String!) {
|
|||||||
displayName
|
displayName
|
||||||
firstName
|
firstName
|
||||||
lastName
|
lastName
|
||||||
|
avatar
|
||||||
creationDate
|
creationDate
|
||||||
uuid
|
uuid
|
||||||
groups {
|
groups {
|
||||||
|
@ -198,8 +198,7 @@ impl Component for UserDetails {
|
|||||||
<>
|
<>
|
||||||
<h3>{u.id.to_string()}</h3>
|
<h3>{u.id.to_string()}</h3>
|
||||||
<UserDetailsForm
|
<UserDetailsForm
|
||||||
user=u.clone()
|
user=u.clone() />
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<NavButton
|
<NavButton
|
||||||
route=AppRoute::ChangePassword(u.id.clone())
|
route=AppRoute::ChangePassword(u.id.clone())
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
components::user_details::User,
|
components::user_details::User,
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
@ -5,9 +7,37 @@ use crate::{
|
|||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use wasm_bindgen::JsCast;
|
||||||
|
use yew::{prelude::*, services::ConsoleService};
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
|
|
||||||
|
#[derive(PartialEq, Clone, Default)]
|
||||||
|
struct JsFile {
|
||||||
|
file: Option<web_sys::File>,
|
||||||
|
contents: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToString for JsFile {
|
||||||
|
fn to_string(&self) -> String {
|
||||||
|
self.file
|
||||||
|
.as_ref()
|
||||||
|
.map(web_sys::File::name)
|
||||||
|
.unwrap_or_else(String::new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, Clone)]
|
#[derive(Model, Validate, PartialEq, Clone)]
|
||||||
pub struct UserModel {
|
pub struct UserModel {
|
||||||
@ -34,6 +64,7 @@ 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>,
|
||||||
|
avatar: JsFile,
|
||||||
/// 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,
|
||||||
}
|
}
|
||||||
@ -43,6 +74,8 @@ pub enum Msg {
|
|||||||
Update,
|
Update,
|
||||||
/// The "Submit" button was clicked.
|
/// The "Submit" button was clicked.
|
||||||
SubmitClicked,
|
SubmitClicked,
|
||||||
|
/// A picked file finished loading.
|
||||||
|
FileLoaded(yew::services::reader::FileData),
|
||||||
/// 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>),
|
||||||
}
|
}
|
||||||
@ -51,16 +84,58 @@ pub enum Msg {
|
|||||||
pub struct Props {
|
pub struct Props {
|
||||||
/// The current user details.
|
/// The current user details.
|
||||||
pub user: User,
|
pub user: User,
|
||||||
/// Callback to report errors (e.g. server error).
|
|
||||||
pub on_error: Callback<Error>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => {
|
||||||
|
let window = web_sys::window().expect("no global `window` exists");
|
||||||
|
let document = window.document().expect("should have a document on window");
|
||||||
|
let input = document
|
||||||
|
.get_element_by_id("avatarInput")
|
||||||
|
.expect("Form field avatarInput should be present")
|
||||||
|
.dyn_into::<web_sys::HtmlInputElement>()
|
||||||
|
.expect("Should be an HtmlInputElement");
|
||||||
|
ConsoleService::log("Form update");
|
||||||
|
if let Some(files) = input.files() {
|
||||||
|
ConsoleService::log("Got file list");
|
||||||
|
if files.length() > 0 {
|
||||||
|
ConsoleService::log("Got a file");
|
||||||
|
let new_avatar = JsFile {
|
||||||
|
file: files.item(0),
|
||||||
|
contents: None,
|
||||||
|
};
|
||||||
|
if self.avatar.file.as_ref().map(|f| f.name())
|
||||||
|
!= new_avatar.file.as_ref().map(|f| f.name())
|
||||||
|
{
|
||||||
|
if let Some(ref file) = new_avatar.file {
|
||||||
|
self.mut_common().read_file(file.clone(), Msg::FileLoaded)?;
|
||||||
|
}
|
||||||
|
self.avatar = new_avatar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
Msg::SubmitClicked => self.submit_user_update_form(),
|
Msg::SubmitClicked => self.submit_user_update_form(),
|
||||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||||
|
Msg::FileLoaded(data) => {
|
||||||
|
self.common.cancel_task();
|
||||||
|
if let Some(file) = &self.avatar.file {
|
||||||
|
if file.name() == data.name {
|
||||||
|
if !is_valid_jpeg(data.content.as_slice()) {
|
||||||
|
// Clear the selection.
|
||||||
|
self.avatar = JsFile::default();
|
||||||
|
bail!("Chosen image is not a valid JPEG");
|
||||||
|
} else {
|
||||||
|
self.avatar.contents = Some(data.content);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,17 +158,14 @@ impl Component for UserDetailsForm {
|
|||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
form: yew_form::Form::new(model),
|
form: yew_form::Form::new(model),
|
||||||
|
avatar: JsFile::default(),
|
||||||
just_updated: false,
|
just_updated: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
self.just_updated = false;
|
self.just_updated = false;
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update(self, msg)
|
||||||
self,
|
|
||||||
msg,
|
|
||||||
self.common.on_error.clone(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||||
@ -102,6 +174,9 @@ impl Component for UserDetailsForm {
|
|||||||
|
|
||||||
fn view(&self) -> Html {
|
fn view(&self) -> Html {
|
||||||
type Field = yew_form::Field<UserModel>;
|
type Field = yew_form::Field<UserModel>;
|
||||||
|
|
||||||
|
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
|
||||||
|
let avatar_string = avatar_base64.as_ref().unwrap_or(&self.common.user.avatar);
|
||||||
html! {
|
html! {
|
||||||
<div class="py-3">
|
<div class="py-3">
|
||||||
<form class="form">
|
<form class="form">
|
||||||
@ -111,7 +186,24 @@ impl Component for UserDetailsForm {
|
|||||||
{"User ID: "}
|
{"User ID: "}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<span id="userId" class="form-constrol-static">{&self.common.user.id}</span>
|
<span id="userId" class="form-constrol-static"><b>{&self.common.user.id}</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group row mb-3">
|
||||||
|
<div class="col-4 col-form-label">
|
||||||
|
<img
|
||||||
|
id="avatarDisplay"
|
||||||
|
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||||
|
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||||
|
alt="Avatar" />
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="avatarInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg"
|
||||||
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group row mb-3">
|
<div class="form-group row mb-3">
|
||||||
@ -214,6 +306,14 @@ impl Component for UserDetailsForm {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{ if let Some(e) = &self.common.error {
|
||||||
|
html! {
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{e.to_string() }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
<div hidden=!self.just_updated>
|
<div hidden=!self.just_updated>
|
||||||
<span>{"User successfully updated!"}</span>
|
<span>{"User successfully updated!"}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -224,9 +324,19 @@ impl Component for UserDetailsForm {
|
|||||||
|
|
||||||
impl UserDetailsForm {
|
impl UserDetailsForm {
|
||||||
fn submit_user_update_form(&mut self) -> Result<bool> {
|
fn submit_user_update_form(&mut self) -> Result<bool> {
|
||||||
|
ConsoleService::log("Submit");
|
||||||
if !self.form.validate() {
|
if !self.form.validate() {
|
||||||
bail!("Invalid inputs");
|
bail!("Invalid inputs");
|
||||||
}
|
}
|
||||||
|
ConsoleService::log("Valid inputs");
|
||||||
|
if let JsFile {
|
||||||
|
file: Some(_),
|
||||||
|
contents: None,
|
||||||
|
} = &self.avatar
|
||||||
|
{
|
||||||
|
bail!("Image file hasn't finished loading, try again");
|
||||||
|
}
|
||||||
|
ConsoleService::log("File is correctly loaded");
|
||||||
let base_user = &self.common.user;
|
let base_user = &self.common.user;
|
||||||
let mut user_input = update_user::UpdateUserInput {
|
let mut user_input = update_user::UpdateUserInput {
|
||||||
id: self.common.user.id.clone(),
|
id: self.common.user.id.clone(),
|
||||||
@ -251,11 +361,14 @@ impl UserDetailsForm {
|
|||||||
if base_user.last_name != model.last_name {
|
if base_user.last_name != model.last_name {
|
||||||
user_input.lastName = Some(model.last_name);
|
user_input.lastName = Some(model.last_name);
|
||||||
}
|
}
|
||||||
|
user_input.avatar = maybe_to_base64(&self.avatar)?;
|
||||||
// Nothing changed.
|
// Nothing changed.
|
||||||
if user_input == default_user_input {
|
if user_input == default_user_input {
|
||||||
|
ConsoleService::log("No changes");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let req = update_user::Variables { user: user_input };
|
let req = update_user::Variables { user: user_input };
|
||||||
|
ConsoleService::log("Querying");
|
||||||
self.common.call_graphql::<UpdateUser, _>(
|
self.common.call_graphql::<UpdateUser, _>(
|
||||||
req,
|
req,
|
||||||
Msg::UserUpdated,
|
Msg::UserUpdated,
|
||||||
@ -270,19 +383,44 @@ impl UserDetailsForm {
|
|||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
self.common.user = User {
|
self.common.user.email = model.email;
|
||||||
id: self.common.user.id.clone(),
|
self.common.user.display_name = model.display_name;
|
||||||
email: model.email,
|
self.common.user.first_name = model.first_name;
|
||||||
display_name: model.display_name,
|
self.common.user.last_name = model.last_name;
|
||||||
first_name: model.first_name,
|
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
|
||||||
last_name: model.last_name,
|
self.common.user.avatar = avatar;
|
||||||
creation_date: self.common.user.creation_date,
|
}
|
||||||
uuid: self.common.user.uuid.clone(),
|
|
||||||
groups: self.common.user.groups.clone(),
|
|
||||||
};
|
|
||||||
self.just_updated = true;
|
self.just_updated = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||||
|
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||||
|
.decode()
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_to_base64(file: &JsFile) -> Result<Option<String>> {
|
||||||
|
match file {
|
||||||
|
JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: _,
|
||||||
|
} => Ok(None),
|
||||||
|
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(Some(base64::encode(data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -26,7 +26,11 @@ use anyhow::{Error, Result};
|
|||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::{
|
use yew::{
|
||||||
prelude::*,
|
prelude::*,
|
||||||
services::{fetch::FetchTask, ConsoleService},
|
services::{
|
||||||
|
fetch::FetchTask,
|
||||||
|
reader::{FileData, ReaderService, ReaderTask},
|
||||||
|
ConsoleService,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use yewtil::NeqAssign;
|
use yewtil::NeqAssign;
|
||||||
|
|
||||||
@ -40,13 +44,34 @@ pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
|||||||
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AnyTask {
|
||||||
|
None,
|
||||||
|
FetchTask(FetchTask),
|
||||||
|
ReaderTask(ReaderTask),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnyTask {
|
||||||
|
fn is_some(&self) -> bool {
|
||||||
|
!matches!(self, AnyTask::None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Option<FetchTask>> for AnyTask {
|
||||||
|
fn from(task: Option<FetchTask>) -> Self {
|
||||||
|
match task {
|
||||||
|
Some(t) => AnyTask::FetchTask(t),
|
||||||
|
None => AnyTask::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Structure that contains the common parts needed by most components.
|
/// Structure that contains the common parts needed by most components.
|
||||||
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
||||||
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
||||||
link: ComponentLink<C>,
|
link: ComponentLink<C>,
|
||||||
pub props: <C as Component>::Properties,
|
pub props: <C as Component>::Properties,
|
||||||
pub error: Option<Error>,
|
pub error: Option<Error>,
|
||||||
task: Option<FetchTask>,
|
task: AnyTask,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||||
@ -57,7 +82,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
|||||||
|
|
||||||
/// Cancel any background task.
|
/// Cancel any background task.
|
||||||
pub fn cancel_task(&mut self) {
|
pub fn cancel_task(&mut self) {
|
||||||
self.task = None;
|
self.task = AnyTask::None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
|
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
|
||||||
@ -65,7 +90,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
|||||||
link,
|
link,
|
||||||
props,
|
props,
|
||||||
error: None,
|
error: None,
|
||||||
task: None,
|
task: AnyTask::None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,7 +156,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
|||||||
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
||||||
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
||||||
{
|
{
|
||||||
self.task = Some(method(req, self.link.callback_once(callback))?);
|
self.task = AnyTask::FetchTask(method(req, self.link.callback_once(callback))?);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,7 +181,19 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
|||||||
ConsoleService::log(&e.to_string());
|
ConsoleService::log(&e.to_string());
|
||||||
self.error = Some(e);
|
self.error = Some(e);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok()
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_file<Cb>(&mut self, file: web_sys::File, callback: Cb) -> Result<()>
|
||||||
|
where
|
||||||
|
Cb: FnOnce(FileData) -> <C as Component>::Message + 'static,
|
||||||
|
{
|
||||||
|
self.task = AnyTask::ReaderTask(ReaderService::read_file(
|
||||||
|
file,
|
||||||
|
self.link.callback_once(callback),
|
||||||
|
)?);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user