app: update yew to 0.19

This is a massive change to all the components, since the interface
changed.

There are opportunities to greatly simplify some components by turning
them into functional_components, but this work has tried to stay as
mechanical as possible.
This commit is contained in:
Valentin Tolmer
2023-03-08 18:05:08 +01:00
committed by nitnelave
parent 8d44717588
commit b2cfc0ed03
25 changed files with 893 additions and 1127 deletions

View File

@@ -1,138 +1,84 @@
use super::cookies::set_cookie;
use anyhow::{anyhow, Context, Result};
use gloo_net::http::{Method, Request};
use graphql_client::GraphQLQuery;
use lldap_auth::{login, registration, JWTClaims};
use yew::{
callback::Callback,
format::Json,
services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response},
};
use serde::{de::DeserializeOwned, Serialize};
use web_sys::RequestCredentials;
#[derive(Default)]
pub struct HostService {}
fn get_default_options() -> FetchOptions {
FetchOptions {
credentials: Some(Credentials::SameOrigin),
..FetchOptions::default()
}
}
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
use jwt::*;
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
Ok(token.claims().clone())
}
fn create_handler<Resp, CallbackResult, F>(
callback: Callback<Result<CallbackResult>>,
handler: F,
) -> Callback<Response<Result<Resp>>>
where
F: Fn(http::StatusCode, Resp) -> Result<CallbackResult> + 'static,
CallbackResult: 'static,
{
Callback::once(move |response: Response<Result<Resp>>| {
let (meta, maybe_data) = response.into_parts();
let message = maybe_data
.context("Could not reach server")
.and_then(|data| handler(meta.status, data));
callback.emit(message)
})
}
const NO_BODY: Option<()> = None;
struct RequestBody<T>(T);
impl<'a, R> From<&'a R> for RequestBody<Json<&'a R>>
where
R: serde::ser::Serialize,
{
fn from(request: &'a R) -> Self {
Self(Json(request))
async fn call_server(
url: &str,
body: Option<impl Serialize>,
error_message: &'static str,
) -> Result<String> {
let mut request = Request::new(url)
.header("Content-Type", "application/json")
.credentials(RequestCredentials::SameOrigin);
if let Some(b) = body {
request = request
.body(serde_json::to_string(&b)?)
.method(Method::POST);
}
let response = request.send().await?;
if response.ok() {
Ok(response.text().await?)
} else {
Err(anyhow!(
"{}[{} {}]: {}",
error_message,
response.status(),
response.status_text(),
response.text().await?
))
}
}
impl From<yew::format::Nothing> for RequestBody<yew::format::Nothing> {
fn from(request: yew::format::Nothing) -> Self {
Self(request)
}
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
url: &str,
request: Option<Body>,
error_message: &'static str,
) -> Result<CallbackResult>
where
CallbackResult: DeserializeOwned + 'static,
{
let data = call_server(url, request, error_message).await?;
serde_json::from_str(&data).context("Could not parse response")
}
fn call_server<Req, CallbackResult, F, RB>(
async fn call_server_empty_response_with_error_message<Body: Serialize>(
url: &str,
request: RB,
callback: Callback<Result<CallbackResult>>,
request: Option<Body>,
error_message: &'static str,
parse_response: F,
) -> Result<FetchTask>
where
F: Fn(String) -> Result<CallbackResult> + 'static,
CallbackResult: 'static,
RB: Into<RequestBody<Req>>,
Req: Into<yew::format::Text>,
{
let request = {
// If the request type is empty (if the size is 0), it's a get.
if std::mem::size_of::<RB>() == 0 {
Request::get(url)
} else {
Request::post(url)
}
}
.header("Content-Type", "application/json")
.body(request.into().0)?;
let handler = create_handler(callback, move |status: http::StatusCode, data: String| {
if status.is_success() {
parse_response(data)
} else {
Err(anyhow!("{}[{}]: {}", error_message, status, data))
}
});
FetchService::fetch_with_options(request, get_default_options(), handler)
) -> Result<()> {
call_server(url, request, error_message).await.map(|_| ())
}
fn call_server_json_with_error_message<CallbackResult, RB, Req>(
url: &str,
request: RB,
callback: Callback<Result<CallbackResult>>,
error_message: &'static str,
) -> Result<FetchTask>
where
CallbackResult: serde::de::DeserializeOwned + 'static,
RB: Into<RequestBody<Req>>,
Req: Into<yew::format::Text>,
{
call_server(url, request, callback, error_message, |data: String| {
serde_json::from_str(&data).context("Could not parse response")
})
}
fn call_server_empty_response_with_error_message<RB, Req>(
url: &str,
request: RB,
callback: Callback<Result<()>>,
error_message: &'static str,
) -> Result<FetchTask>
where
RB: Into<RequestBody<Req>>,
Req: Into<yew::format::Text>,
{
call_server(
url,
request,
callback,
error_message,
|_data: String| Ok(()),
)
fn set_cookies_from_jwt(response: login::ServerLoginResponse) -> Result<(String, bool)> {
let jwt_claims = get_claims_from_jwt(response.token.as_str()).context("Could not parse JWT")?;
let is_admin = jwt_claims.groups.contains("lldap_admin");
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
.map(|_| (jwt_claims.user.clone(), is_admin))
.context("Error setting cookie")
}
impl HostService {
pub fn graphql_query<QueryType>(
pub async fn graphql_query<QueryType>(
variables: QueryType::Variables,
callback: Callback<Result<QueryType::ResponseData>>,
error_message: &'static str,
) -> Result<FetchTask>
) -> Result<QueryType::ResponseData>
where
QueryType: GraphQLQuery + 'static,
{
@@ -149,156 +95,103 @@ impl HostService {
)
})
};
let parse_graphql_response = move |data: String| {
serde_json::from_str(&data)
.context("Could not parse response")
.and_then(unwrap_graphql_response)
};
let request_body = QueryType::build_query(variables);
call_server(
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
"/api/graphql",
&request_body,
callback,
Some(request_body),
error_message,
parse_graphql_response,
)
.await
.and_then(unwrap_graphql_response)
}
pub fn login_start(
pub async fn login_start(
request: login::ClientLoginStartRequest,
callback: Callback<Result<Box<login::ServerLoginStartResponse>>>,
) -> Result<FetchTask> {
) -> Result<Box<login::ServerLoginStartResponse>> {
call_server_json_with_error_message(
"/auth/opaque/login/start",
&request,
callback,
Some(request),
"Could not start authentication: ",
)
.await
}
pub fn login_finish(
request: login::ClientLoginFinishRequest,
callback: Callback<Result<(String, bool)>>,
) -> Result<FetchTask> {
let set_cookies = |jwt_claims: JWTClaims| {
let is_admin = jwt_claims.groups.contains("lldap_admin");
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
.map(|_| (jwt_claims.user.clone(), is_admin))
.context("Error clearing cookie")
};
let parse_token = move |data: String| {
serde_json::from_str::<login::ServerLoginResponse>(&data)
.context("Could not parse response")
.and_then(|r| {
get_claims_from_jwt(r.token.as_str())
.context("Could not parse response")
.and_then(set_cookies)
})
};
call_server(
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
"/auth/opaque/login/finish",
&request,
callback,
Some(request),
"Could not finish authentication",
parse_token,
)
.await
.and_then(set_cookies_from_jwt)
}
pub fn register_start(
pub async fn register_start(
request: registration::ClientRegistrationStartRequest,
callback: Callback<Result<Box<registration::ServerRegistrationStartResponse>>>,
) -> Result<FetchTask> {
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
call_server_json_with_error_message(
"/auth/opaque/register/start",
&request,
callback,
Some(request),
"Could not start registration: ",
)
.await
}
pub fn register_finish(
pub async fn register_finish(
request: registration::ClientRegistrationFinishRequest,
callback: Callback<Result<()>>,
) -> Result<FetchTask> {
) -> Result<()> {
call_server_empty_response_with_error_message(
"/auth/opaque/register/finish",
&request,
callback,
Some(request),
"Could not finish registration",
)
.await
}
pub fn refresh(_request: (), callback: Callback<Result<(String, bool)>>) -> Result<FetchTask> {
let set_cookies = |jwt_claims: JWTClaims| {
let is_admin = jwt_claims.groups.contains("lldap_admin");
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
.map(|_| (jwt_claims.user.clone(), is_admin))
.context("Error clearing cookie")
};
let parse_token = move |data: String| {
serde_json::from_str::<login::ServerLoginResponse>(&data)
.context("Could not parse response")
.and_then(|r| {
get_claims_from_jwt(r.token.as_str())
.context("Could not parse response")
.and_then(set_cookies)
})
};
call_server(
pub async fn refresh() -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
"/auth/refresh",
yew::format::Nothing,
callback,
NO_BODY,
"Could not start authentication: ",
parse_token,
)
.await
.and_then(set_cookies_from_jwt)
}
// The `_request` parameter is to make it the same shape as the other functions.
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> {
call_server_empty_response_with_error_message(
"/auth/logout",
yew::format::Nothing,
callback,
"Could not logout",
)
pub async fn logout() -> Result<()> {
call_server_empty_response_with_error_message("/auth/logout", NO_BODY, "Could not logout")
.await
}
pub fn reset_password_step1(
username: &str,
callback: Callback<Result<()>>,
) -> Result<FetchTask> {
pub async fn reset_password_step1(username: String) -> Result<()> {
call_server_empty_response_with_error_message(
&format!("/auth/reset/step1/{}", url_escape::encode_query(username)),
yew::format::Nothing,
callback,
&format!("/auth/reset/step1/{}", url_escape::encode_query(&username)),
NO_BODY,
"Could not initiate password reset",
)
.await
}
pub fn reset_password_step2(
token: &str,
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>,
) -> Result<FetchTask> {
pub async fn reset_password_step2(
token: String,
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
call_server_json_with_error_message(
&format!("/auth/reset/step2/{}", token),
yew::format::Nothing,
callback,
NO_BODY,
"Could not validate token",
)
.await
}
pub fn probe_password_reset(callback: Callback<Result<bool>>) -> Result<FetchTask> {
let request = Request::get("/auth/reset/step1/lldap_unlikely_very_long_user_name")
.header("Content-Type", "application/json")
.body(yew::format::Nothing)?;
FetchService::fetch_with_options(
request,
get_default_options(),
create_handler(callback, move |status: http::StatusCode, _data: String| {
Ok(status != http::StatusCode::NOT_FOUND)
}),
pub async fn probe_password_reset() -> Result<bool> {
Ok(
gloo_net::http::Request::get("/auth/reset/step1/lldap_unlikely_very_long_user_name")
.header("Content-Type", "application/json")
.send()
.await?
.status()
!= http::StatusCode::NOT_FOUND,
)
}
}

View File

@@ -21,88 +21,62 @@
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
//! take care of error and task handling.
use std::{
future::Future,
marker::PhantomData,
sync::{Arc, Mutex},
};
use crate::infra::api::HostService;
use anyhow::{Error, Result};
use gloo_console::{error, log};
use gloo_console::error;
use graphql_client::GraphQLQuery;
use yew::{
prelude::*,
services::{
fetch::FetchTask,
reader::{FileData, ReaderService, ReaderTask},
},
};
use yewtil::NeqAssign;
use yew::prelude::*;
/// Trait required for common components.
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
/// Handle the incoming message. If an error is returned here, any running task will be
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
/// component will be refreshed.
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool>;
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool>;
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
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.
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
pub struct CommonComponentParts<C: CommonComponent<C>> {
link: ComponentLink<C>,
pub props: <C as Component>::Properties,
pub error: Option<Error>,
task: AnyTask,
is_task_running: Arc<Mutex<bool>>,
_phantom: PhantomData<C>,
}
impl<C: CommonComponent<C>> CommonComponentParts<C> {
pub fn create() -> Self {
CommonComponentParts {
error: None,
is_task_running: Arc::new(Mutex::new(false)),
_phantom: PhantomData::<C>,
}
}
/// Whether there is a currently running task in the background.
pub fn is_task_running(&self) -> bool {
self.task.is_some()
}
/// Cancel any background task.
pub fn cancel_task(&mut self) {
self.task = AnyTask::None;
}
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
Self {
link,
props,
error: None,
task: AnyTask::None,
}
*self.is_task_running.lock().unwrap()
}
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
/// [`CommonComponent::handle_msg`] and handle any resulting error.
pub fn update(com: &mut C, msg: <C as Component>::Message) -> ShouldRender {
pub fn update(com: &mut C, ctx: &Context<C>, msg: <C as Component>::Message) -> bool {
com.mut_common().error = None;
match com.handle_msg(msg) {
match com.handle_msg(ctx, msg) {
Err(e) => {
error!(&e.to_string());
com.mut_common().error = Some(e);
com.mut_common().cancel_task();
assert!(!*com.mut_common().is_task_running.lock().unwrap());
true
}
Ok(b) => b,
@@ -112,10 +86,11 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
/// Same as above, but the resulting error is instead passed to the reporting function.
pub fn update_and_report_error(
com: &mut C,
ctx: &Context<C>,
msg: <C as Component>::Message,
report_fn: Callback<Error>,
) -> ShouldRender {
let should_render = Self::update(com, msg);
) -> bool {
let should_render = Self::update(com, ctx, msg);
com.mut_common()
.error
.take()
@@ -126,38 +101,24 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
.unwrap_or(should_render)
}
/// This can be called from [`yew::prelude::Component::update`]: it will check if the
/// properties have changed and return whether the component should update.
pub fn change(&mut self, props: <C as Component>::Properties) -> ShouldRender
where
<C as yew::Component>::Properties: std::cmp::PartialEq,
{
self.props.neq_assign(props)
}
/// Create a callback from the link.
pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
where
M: Into<C::Message>,
F: Fn(IN) -> M + 'static,
{
self.link.callback(function)
}
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
/// result. Returns whether _starting the call_ failed.
pub fn call_backend<M, Req, Cb, Resp>(
&mut self,
method: M,
req: Req,
callback: Cb,
) -> Result<()>
/// result.
pub fn call_backend<Fut, Cb, Resp>(&mut self, ctx: &Context<C>, fut: Fut, callback: Cb)
where
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
Fut: Future<Output = Resp> + 'static,
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
{
self.task = AnyTask::FetchTask(method(req, self.link.callback_once(callback))?);
Ok(())
{
let mut running = self.is_task_running.lock().unwrap();
assert!(!*running);
*running = true;
}
let is_task_running = self.is_task_running.clone();
ctx.link().send_future(async move {
let res = fut.await;
*is_task_running.lock().unwrap() = false;
callback(res)
});
}
/// Call the backend with a GraphQL query.
@@ -165,6 +126,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
/// `EnumCallback` should usually be left as `_`.
pub fn call_graphql<QueryType, EnumCallback>(
&mut self,
ctx: &Context<C>,
variables: QueryType::Variables,
enum_callback: EnumCallback,
error_message: &'static str,
@@ -172,41 +134,10 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
QueryType: GraphQLQuery + 'static,
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
{
self.task = HostService::graphql_query::<QueryType>(
variables,
self.link.callback(enum_callback),
error_message,
)
.map_err::<(), _>(|e| {
log!(&e.to_string());
self.error = Some(e);
})
.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(())
}
}
impl<C: Component + CommonComponent<C>> std::ops::Deref for CommonComponentParts<C> {
type Target = <C as Component>::Properties;
fn deref(&self) -> &<Self as std::ops::Deref>::Target {
&self.props
}
}
impl<C: Component + CommonComponent<C>> std::ops::DerefMut for CommonComponentParts<C> {
fn deref_mut(&mut self) -> &mut <Self as std::ops::Deref>::Target {
&mut self.props
self.call_backend(
ctx,
HostService::graphql_query::<QueryType>(variables, error_message),
enum_callback,
);
}
}