diff --git a/web_frontend/Cargo.toml b/web_frontend/Cargo.toml index 5b3dca8..38caf90 100644 --- a/web_frontend/Cargo.toml +++ b/web_frontend/Cargo.toml @@ -18,7 +18,7 @@ log = "0.4.17" wasm-logger = "0.2.0" gloo-timers = { version = "0.4", features = ["futures"] } serde-tuple-vec-map = "1.0.1" -web-sys = { version = "0.3.77", features = ["InputEvent", "InputEventInit", "HtmlSelectElement", "Window", "Location", "Clipboard", "Navigator", "SubmitEvent", "KeyboardEvent"]} +web-sys = { version = "0.3.77", features = ["InputEvent", "InputEventInit", "HtmlSelectElement", "Window", "Location", "Clipboard", "Navigator", "SubmitEvent", "KeyboardEvent", "DomTokenList"]} gloo-net = "0.6" serde_with = "3.8.1" url = "2.5.0" diff --git a/web_frontend/src/account.rs b/web_frontend/src/account.rs index 280547c..e0e176a 100644 --- a/web_frontend/src/account.rs +++ b/web_frontend/src/account.rs @@ -5,7 +5,7 @@ use web_sys::{HtmlInputElement, SubmitEvent}; use yew::prelude::*; use yew::{html, Callback, Component, Context, Html}; -use crate::auth::AuthStatus; +use crate::{api, auth::AuthStatus}; #[derive(Serialize)] struct ChangePasswordPayload { @@ -20,6 +20,7 @@ struct ApiKeyResponse { pub enum Message { StatusLoaded(Option), + LoadApiKey, ApiKeyLoaded(String), UpdateCurrentPassword(String), UpdateNewPassword(String), @@ -62,17 +63,7 @@ impl Component for AccountSettings { } }); - let link = ctx.link().clone(); - spawn_local(async move { - match Request::get("/api/auth/api-key").send().await { - Ok(response) if response.ok() => { - if let Ok(payload) = response.json::().await { - link.send_message(Message::ApiKeyLoaded(payload.api_key)); - } - } - _ => {} - } - }); + ctx.link().send_message(Message::LoadApiKey); Self { username: None, @@ -84,17 +75,34 @@ impl Component for AccountSettings { password_success: false, password_submitting: false, api_key_error: None, - api_key_busy: false, + api_key_busy: true, } } fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { + Message::LoadApiKey => { + self.api_key_busy = true; + self.api_key_error = None; + let link = ctx.link().clone(); + spawn_local(async move { + link.send_message( + match api::get_json::("/api/auth/api-key").await { + Ok(payload) => Message::ApiKeyLoaded(payload.api_key), + Err(error) => Message::ApiKeyRotateFailed(format!( + "Could not load API key: {error}" + )), + }, + ); + }); + true + } Message::StatusLoaded(username) => { self.username = username; true } Message::ApiKeyLoaded(key) => { + self.api_key_busy = false; self.api_key = Some(key); true } @@ -233,10 +241,14 @@ impl Component for AccountSettings { let on_rotate = link.callback(|_| Message::RotateApiKey); let username_display = self.username.clone().unwrap_or_else(|| "—".to_string()); - let api_key_display = self - .api_key - .clone() - .unwrap_or_else(|| "Loading...".to_string()); + let api_key_display = self.api_key.clone().unwrap_or_else(|| { + if self.api_key_busy { + "Loading..." + } else { + "Unavailable" + } + .to_string() + }); html! { <> @@ -288,14 +300,17 @@ impl Component for AccountSettings { /> { if let Some(msg) = &self.api_key_error { - html! {
{msg.clone()}
} + html! { } } else { html!{} } } + if self.api_key.is_none() && self.api_key_error.is_some() { + + }

{ "Rotating invalidates the previous key immediately." } diff --git a/web_frontend/src/api.rs b/web_frontend/src/api.rs new file mode 100644 index 0000000..76c8c6f --- /dev/null +++ b/web_frontend/src/api.rs @@ -0,0 +1,32 @@ +use gloo_net::http::{Request, RequestBuilder, Response}; +use serde::{de::DeserializeOwned, Serialize}; + +pub async fn response_error(response: Response) -> String { + let status = response.status(); + response + .json::() + .await + .map(|error| error.error) + .unwrap_or_else(|_| format!("HTTP {status}")) +} + +pub async fn get_json(url: &str) -> Result { + let response = Request::get(url).send().await.map_err(|e| e.to_string())?; + if !response.ok() { + return Err(response_error(response).await); + } + response.json().await.map_err(|e| e.to_string()) +} + +pub async fn send_json(request: RequestBuilder, body: &T) -> Result<(), String> { + let response = request + .json(body) + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())?; + if !response.ok() { + return Err(response_error(response).await); + } + Ok(()) +} diff --git a/web_frontend/src/auth.rs b/web_frontend/src/auth.rs index 21f60c3..82b25d5 100644 --- a/web_frontend/src/auth.rs +++ b/web_frontend/src/auth.rs @@ -27,6 +27,7 @@ struct SetupPayload { #[derive(Debug, Clone, PartialEq)] enum GateState { Loading, + Failed, NeedsSetup, NeedsLogin, Authenticated(Option), @@ -86,7 +87,7 @@ impl Component for AuthGate { true } GateMessage::StatusLoadFailed => { - self.state = GateState::NeedsLogin; + self.state = GateState::Failed; true } GateMessage::SetupCompleted(username) => { @@ -107,6 +108,15 @@ impl Component for AuthGate { fn view(&self, ctx: &Context) -> Html { match &self.state { GateState::Loading => render_loading(), + GateState::Failed => render_card( + "Unable to load Privaxy", + html! { + <> +

+ + + }, + ), GateState::NeedsSetup => { let on_setup = ctx .link() diff --git a/web_frontend/src/blocking_enabled.rs b/web_frontend/src/blocking_enabled.rs index 41ef1ac..def547d 100644 --- a/web_frontend/src/blocking_enabled.rs +++ b/web_frontend/src/blocking_enabled.rs @@ -1,24 +1,19 @@ +use crate::api; +use crate::button::{ButtonColor, ButtonState, PrivaxyButton}; use gloo_net::http::Request; use wasm_bindgen_futures::spawn_local; -use yew::{classes, html, Component, Context, Html}; - -pub enum ButtonState { - Loading, - Ready, -} +use yew::{html, Component, Context, Html}; pub struct BlockingEnabled { - blocking_enabled: bool, - button_state: ButtonState, + enabled: Option, + busy: bool, + error: Option, } -#[derive(Debug)] pub enum Message { - EnableBlocking, - DisableBlocking, - BlockingEnabled, - BlockingDisabled, - SetCurrentBlockingState, + Load, + Toggle, + Completed(Result), } impl Component for BlockingEnabled { @@ -26,138 +21,75 @@ impl Component for BlockingEnabled { type Properties = (); fn create(ctx: &Context) -> Self { - ctx.link().send_message(Message::SetCurrentBlockingState); - + ctx.link().send_message(Message::Load); Self { - blocking_enabled: true, - button_state: ButtonState::Loading, + enabled: None, + busy: true, + error: None, } } - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - let base_request = - Request::put("/api/blocking-enabled").header("Content-Type", "application/json"); - - let message_callback = ctx.link().callback(|message: Message| message); - + fn update(&mut self, ctx: &Context, msg: Message) -> bool { match msg { - Message::EnableBlocking => { - self.button_state = ButtonState::Loading; - - let request = base_request.body("true").unwrap(); - + Message::Load => { + self.busy = true; + self.error = None; + let link = ctx.link().clone(); spawn_local(async move { - match request.send().await { - Ok(response) => { - if response.ok() { - message_callback.emit(Message::BlockingEnabled) - } - } - Err(_) => message_callback.emit(Message::BlockingDisabled), - } + link.send_message(Message::Completed( + api::get_json("/api/blocking-enabled").await, + )); }); } - Message::DisableBlocking => { - self.button_state = ButtonState::Loading; - - let request = base_request.body("false").unwrap(); - + Message::Toggle => { + if self.busy { + return false; + } + let Some(enabled) = self.enabled.map(|enabled| !enabled) else { + return false; + }; + self.busy = true; + self.error = None; + let link = ctx.link().clone(); spawn_local(async move { - if let Ok(response) = request.send().await { - if response.ok() { - message_callback.emit(Message::BlockingDisabled); - } - } + let result = + api::send_json(Request::put("/api/blocking-enabled"), &enabled).await; + link.send_message(Message::Completed(result.map(|()| enabled))); }); } - Message::BlockingEnabled => { - self.button_state = ButtonState::Ready; - self.blocking_enabled = true; - } - Message::BlockingDisabled => { - self.button_state = ButtonState::Ready; - self.blocking_enabled = false; - } - Message::SetCurrentBlockingState => { - let request = Request::get("/api/blocking-enabled"); - - spawn_local(async move { - if let Ok(response) = request.send().await { - if response.ok() { - if let Ok(value) = response.json::().await { - if value { - message_callback.emit(Message::BlockingEnabled) - } else { - message_callback.emit(Message::BlockingDisabled) - } - }; - } + Message::Completed(result) => { + self.busy = false; + match result { + Ok(enabled) => self.enabled = Some(enabled), + Err(error) => { + self.error = Some(format!("Could not update blocking status: {error}")) } - }); + } } } - true } fn view(&self, ctx: &Context) -> Html { - let enable_blocking = ctx.link().callback(|_| Message::EnableBlocking); - let disable_blocking = ctx.link().callback(|_| Message::DisableBlocking); - - let mut button_classes = classes!( - "inline-flex", - "items-center", - "justify-center", - "px-4", - "py-2", - "border", - "transition", - "ease-in-out", - "duration-150", - "border-transparent", - "text-sm", - "text-sm", - "font-medium", - "rounded-md", - "shadow-sm", - "text-white", - "focus:outline-none", - "focus:ring-2", - "focus:ring-offset-2", - "focus:ring-offset-gray-100", - ); - - if let ButtonState::Loading = self.button_state { - button_classes.push("opacity-50"); - button_classes.push("cursor-not-allowed"); - } - - if self.blocking_enabled { - html! { - - } + let state = if self.busy { + ButtonState::Loading } else { - html! { - - } + ButtonState::Enabled + }; + let (color, button_text) = match self.enabled { + Some(true) => (ButtonColor::Red, "Pause blocking"), + Some(false) => (ButtonColor::Green, "Resume blocking"), + None => (ButtonColor::Gray, "Retry blocking status"), + }; + let loaded = self.enabled.is_some(); + html! { +
+ + if let Some(error) = &self.error { + + } +
} } } diff --git a/web_frontend/src/button.rs b/web_frontend/src/button.rs index f1eadbb..11a79f0 100644 --- a/web_frontend/src/button.rs +++ b/web_frontend/src/button.rs @@ -97,6 +97,7 @@ impl Component for PrivaxyButton { }; html! { + } else {

{"Loading..."}

} + }; } let on_toggle = ctx.link().callback(|e: MouseEvent| { @@ -142,17 +162,18 @@ impl Component for DebugSettingsPage {

{ "Debug" }

+ if let Some(error) = &self.error { }
{ "Scriptlet diagnostics" }
-
-
-
+
+
+
{ "Log scriptlet errors to console" }
-
+
-
+

{ "Surface errors thrown by injected uBO scriptlets in the page's developer console as " } { "[privaxy scriptlet]" } { " entries, instead of silently swallowing them. Noisy and reveals that Privaxy is intercepting the page \u{2014} leave off unless troubleshooting." }

- if let Some(error) = &self.error { -

{ error.clone() }

- }
@@ -178,14 +196,14 @@ impl Component for DebugSettingsPage {
{ "Logging" }
-
-
-
+
+
+
{ "Log level" }
-
+
().expect("event target should be a select element"); let value = select.value(); @@ -289,7 +305,7 @@ impl Component for AddFilterComponent {
().expect("event target should be an input element"); @@ -303,7 +319,7 @@ impl Component for AddFilterComponent {
().expect("event target should be an input element"); @@ -315,7 +331,7 @@ impl Component for AddFilterComponent {
-
+
} @@ -386,9 +402,10 @@ pub struct FilterRefreshResult { pub enum Message { Load, Display(FilterConfiguration), + Failed(String), UpdateFilterSelection((String, bool)), Save, - ChangesSaved, + ChangesSaved(FilterConfiguration), AckChanges, OpenEdit(Filter), CloseEdit, @@ -399,6 +416,9 @@ pub enum Message { pub struct Filters { filter_configuration: Option, + loading: bool, + saving: bool, + error: Option, filter_configuration_before_changes: Option, changes_saved: bool, editing_filter: Option, @@ -435,6 +455,9 @@ impl Component for Filters { Self { filter_configuration: None, + loading: true, + saving: false, + error: None, filter_configuration_before_changes: None, changes_saved: false, editing_filter: None, @@ -446,69 +469,77 @@ impl Component for Filters { fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { - Message::Display(filter_configuration) => { - log::debug!("Displaying"); - self.filter_configuration = Some(filter_configuration.clone()); - self.filter_configuration_before_changes = Some(filter_configuration); - } - Message::Load => { - log::debug!("Retrieving filters.."); - let request = Request::get("/api/filters"); - log::debug!("Request: {:?}", request); - let message_callback = ctx.link().callback(|message: Message| message); - log::debug!("Message callback: {:?}", message_callback); - - spawn_local(async move { - if let Ok(response) = request.send().await { - log::debug!("Response: {:?}", response); - if response.ok() { - log::debug!("Response OK"); - if let Ok(body) = response.text().await { - let cursor = Cursor::new(body); - let stream = StreamDeserializer::new(IoRead::new(cursor)); - for result in stream { - match result { - Ok(filter_configuration) => message_callback - .emit(Message::Display(filter_configuration)), - Err(e) => log::error!("Failed to parse chunk: {:?}", e), - } - } + Message::Display(remote) => { + let mut draft = remote.clone(); + // Adding, removing or editing a list must not discard pending + // checkbox changes elsewhere on this page. + if let (Some(current), Some(saved)) = ( + &self.filter_configuration, + &self.filter_configuration_before_changes, + ) { + for filter in &mut draft { + if let (Some(current), Some(saved)) = ( + current.iter().find(|f| f.file_name == filter.file_name), + saved.iter().find(|f| f.file_name == filter.file_name), + ) { + if current.enabled != saved.enabled { + filter.enabled = current.enabled; } } } + } + self.filter_configuration = Some(draft); + self.filter_configuration_before_changes = Some(remote); + self.loading = false; + } + Message::Failed(error) => { + self.error = Some(error); + self.loading = false; + self.saving = false; + } + Message::Load => { + self.loading = true; + self.error = None; + let link = ctx.link().clone(); + spawn_local(async move { + link.send_message(match api::get_json("/api/filters").await { + Ok(filters) => Message::Display(filters), + Err(error) => Message::Failed(format!("Could not load filters: {error}")), + }); }); } Message::Save => { - if !self.configuration_has_changed() { + if self.loading + || self.saving + || self.refreshing + || !self.configuration_has_changed() + { return false; } - let request_body = self - .filter_configuration - .as_ref() - .unwrap() + let Some(submitted) = self.filter_configuration.clone() else { + return false; + }; + self.saving = true; + self.error = None; + self.changes_saved = false; + let request_body = submitted .iter() .map(|filter| FilterStatusChangeRequest { enabled: filter.enabled, file_name: filter.file_name.clone(), }) .collect::>(); - - let request = Request::put("/api/filters") - .header("Content-Type", "application/json") - .body(serde_json::to_string(&request_body).unwrap()) - .unwrap(); - - let callback = ctx.link().callback(|message: Message| message); - + let link = ctx.link().clone(); spawn_local(async move { - if let Ok(response) = request.send().await { - if response.ok() { - callback.emit(Message::ChangesSaved); - } - } + link.send_message( + match api::send_json(Request::put("/api/filters"), &request_body).await { + Ok(()) => Message::ChangesSaved(submitted), + Err(error) => { + Message::Failed(format!("Could not save filters: {error}")) + } + }, + ); }); - - log::info!("Save") } Message::UpdateFilterSelection((filter_name, enabled)) => { self.changes_saved = false; @@ -523,9 +554,10 @@ impl Component for Filters { filter.enabled = enabled; } } - Message::ChangesSaved => { - self.changes_saved = true; - self.filter_configuration_before_changes = self.filter_configuration.clone(); + Message::ChangesSaved(submitted) => { + self.saving = false; + self.filter_configuration_before_changes = Some(submitted); + self.changes_saved = !self.configuration_has_changed(); } Message::AckChanges => self.changes_saved = false, Message::OpenEdit(filter) => self.editing_filter = Some(filter), @@ -535,7 +567,11 @@ impl Component for Filters { ctx.link().send_message(Message::Load); } Message::RefreshLists => { - if self.refreshing || self.configuration_has_changed() { + if self.loading + || self.saving + || self.refreshing + || self.configuration_has_changed() + { return false; } self.refreshing = true; @@ -572,7 +608,9 @@ impl Component for Filters { fn view(&self, ctx: &Context) -> Html { log::debug!("At view."); - let save_button_state = if !self.configuration_has_changed() { + let save_button_state = if self.saving { + ButtonState::Loading + } else if self.loading || self.refreshing || !self.configuration_has_changed() { ButtonState::Disabled } else { ButtonState::Enabled @@ -605,6 +643,7 @@ impl Component for Filters { html! {
@@ -683,7 +722,9 @@ impl Component for Filters { let refresh_state = if self.refreshing { ButtonState::Loading - } else if self.configuration_has_changed() + } else if self.saving + || self.loading + || self.configuration_has_changed() || !self .filter_configuration .as_ref() @@ -722,6 +763,14 @@ impl Component for Filters { None => html! {}, }; + let error_banner = self.error.as_ref().map(|error| html! { +
+ + if self.filter_configuration.is_none() { + + } +
+ }).unwrap_or_default(); match &self.filter_configuration { Some(filter_configuration) => { html! { @@ -730,10 +779,11 @@ impl Component for Filters { {success_banner} + {error_banner} { edit_modal }
- - + + {save_button!(save_callback, save_button_state)}
} } - // This realistically loads way too fast for a loader to be useful. Adding one would just add - // unwanted flickering. - None => html! {{ title }}, + None => { + html! { <>{title}{error_banner} if self.loading {

{"Loading filters…"}

} } + } } } } diff --git a/web_frontend/src/general.rs b/web_frontend/src/general.rs index 7c0f3ee..f619037 100644 --- a/web_frontend/src/general.rs +++ b/web_frontend/src/general.rs @@ -2,8 +2,7 @@ use crate::button::ButtonState; use crate::button::{get_css, ButtonColor}; use crate::failure_banner; use crate::success_banner; -use crate::{save_button, ApiError}; -use gloo_net::http::Request; +use crate::{api, save_button, ApiError}; use gloo_utils::format::JsValueSerdeExt; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -24,6 +23,7 @@ pub enum Message { Load, Save, NetworkLoadSuccess(NetworkConfig), + LoadFailed(String), UpdateProxyPort(String), UpdateBindAddr(String), UpdateWebPort(String), @@ -223,7 +223,9 @@ impl NetworkSettings { Ok(()) } else { log::error!("Failed to save network config"); - return Err(resp.json::().await.unwrap()); + Err(ApiError { + error: api::response_error(resp).await, + }) } } Err(err) => { @@ -249,6 +251,8 @@ pub(crate) struct GeneralSettings { network_settings: Option, ca_config: CaConfig, loading: bool, + saving: bool, + load_error: Option, #[allow(dead_code)] save_callback: Callback<()>, show_error: bool, @@ -305,6 +309,8 @@ impl Component for GeneralSettings { }, network_settings: None, loading: true, + saving: false, + load_error: None, save_callback: Callback::noop(), show_success: false, show_error: false, @@ -315,31 +321,27 @@ impl Component for GeneralSettings { fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { Message::Load => { + self.loading = true; + self.load_error = None; let link = ctx.link().clone(); spawn_local(async move { - let request = Request::get("/api/settings/network"); - match request.send().await { - Ok(response) => { - if response.ok() { - if let Ok(network_config) = response.json::().await { - link.send_message(Message::NetworkLoadSuccess(network_config)); - } - } else { - log::error!( - "Failed to load network config: {:?}", - response.status() - ); - } - } - Err(err) => { - log::error!("Request error: {:?}", err); - } - } + link.send_message(match api::get_json("/api/settings/network").await { + Ok(config) => Message::NetworkLoadSuccess(config), + Err(error) => Message::LoadFailed(error), + }); }); + } + Message::LoadFailed(error) => { self.loading = false; - self.changes_saved = true; + self.load_error = Some(error); } Message::Save => { + if self.loading || self.saving || !self.config_has_changed() || !self.validate() { + return false; + } + self.saving = true; + self.show_error = false; + self.show_success = false; let link = ctx.link().clone(); let network_settings = self.network_settings.clone(); let ca_config = self.ca_config.clone(); @@ -383,6 +385,7 @@ impl Component for GeneralSettings { }); } Message::SaveFailed(err) => { + self.saving = false; let error_msg = err.clone().error.to_string(); self.changes_saved = false; self.show_success = false; @@ -390,6 +393,7 @@ impl Component for GeneralSettings { self.err_msg = error_msg; } Message::SaveSuccess => { + self.saving = false; self.changes_saved = true; self.show_success = true; self.show_error = false; @@ -579,14 +583,14 @@ impl Component for GeneralSettings { error: Option<&String>, description: &str| { html! { -
-
-
{ setting_name }
-
- +
+
+
{ setting_name }
+
+
-
+

{description}

if let Some(error_msg) = error {

{error_msg}

@@ -601,14 +605,14 @@ impl Component for GeneralSettings { oninput: Callback, description: &str| { html! { -
-
-
{ setting_name }
-
- +
+
+
{ setting_name }
+
+
-
+

{description}

@@ -748,7 +752,9 @@ impl Component for GeneralSettings {
} }; - let save_button_state = if self.config_has_changed() && self.validate() { + let save_button_state = if self.saving { + ButtonState::Loading + } else if !self.loading && self.config_has_changed() && self.validate() { ButtonState::Enabled } else { ButtonState::Disabled @@ -779,12 +785,18 @@ impl Component for GeneralSettings { { title } { success_banner_html } { failure_banner_html } + if let Some(error) = &self.load_error { + + + } +
if let Some(network_settings) = &self.network_settings { {render_category("Network", SettingCategories::Network(network_settings.clone()))} - } else { -
{"Loading..."}
+ } else if self.loading { +
{"Loading..."}
} {render_category("Certificate", SettingCategories::Certificate(self.ca_config.clone()))} +
{save_button!(save_callback, save_button_state)} @@ -802,23 +814,23 @@ fn render_certificate_setting( ) -> Html { let input_id = setting_name.to_string().to_lowercase().replace(" ", "_"); html! { -
-
-
{ setting_name }
-
+
+
+
{ setting_name }
+