diff --git a/backend/src/api/endpoints/v1_api_user.rs b/backend/src/api/endpoints/v1_api_user.rs index c5e25035f..e07975bfd 100644 --- a/backend/src/api/endpoints/v1_api_user.rs +++ b/backend/src/api/endpoints/v1_api_user.rs @@ -1,11 +1,13 @@ +use std::path::PathBuf; use crate::api::model::AppState; -use crate::model::ProxyUserCredentials; +use crate::model::{ApiProxyConfig, ProxyUserCredentials, TargetUser}; use crate::repository::user_repository::store_api_user; use axum::response::IntoResponse; use axum::Router; use serde_json::json; use shared::model::{ApiProxyConfigDto, ProxyUserCredentialsDto}; use std::sync::Arc; +use shared::utils::mask_credentials; async fn save_config_api_proxy_user( method: axum::http::Method, @@ -13,76 +15,129 @@ async fn save_config_api_proxy_user( axum::extract::Path(target_name): axum::extract::Path, axum::extract::Json(mut credential): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { + let virtual_file = PathBuf::from("api_proxy"); + let _lock = app_state.app_config.file_locks.write_lock(&virtual_file).await; + credential.prepare(); if let Err(err) = credential.validate() { - return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response(); + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(json!({"error": err.to_string()})) + ).into_response(); } let is_update = method == axum::http::Method::PUT; - if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() { - let mut api_proxy = (*old_api_proxy).clone(); - let mut target_found = false; - for target_user in &api_proxy.user { - if target_user.target == target_name { - target_found = true; - } - for user in &target_user.credentials { - if !is_update && user.username == credential.username { - return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Duplicate username {}", &credential.username)}))).into_response(); - } - if let (Some(u), Some(c)) = (&user.token, &credential.token) { - if u == c && user.username != credential.username { - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(json!({"error": format!("Duplicate token {c}")})) - ).into_response(); - } + let mut api_proxy = if let Some(old) = app_state.app_config.api_proxy.load().clone() { + (*old).clone() + } else { + ApiProxyConfig::default() + }; + + // ---------- Search for existing Target and existing User ---------- + let mut existing_target_index: Option = None; // index of target (target_name), falls vorhanden + let mut existing_user_target_index: Option = None; // index of existing users target + let mut existing_user_index: Option = None; // index of the user in the targets credentials list + + for (t_idx, target_user) in api_proxy.user.iter().enumerate() { + if target_user.target == target_name { + existing_target_index = Some(t_idx); + } + for (u_idx, user) in target_user.credentials.iter().enumerate() { + if let (Some(u), Some(c)) = (&user.token, &credential.token) { + if u == c && user.username != credential.username { + return ( axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Duplicate token {}", mask_credentials(c))})) ).into_response(); } } - } - - if is_update && !target_found { - return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Target not found {target_name}")}))).into_response(); - } - - for target in &mut api_proxy.user { - if target.target == target_name { - if is_update { - let mut updated = false; - for user in &mut target.credentials { - if user.username == credential.username { - *user = ProxyUserCredentials::from(&credential); - updated = true; - break; - } - } - if !updated { - return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("User {} not found in target {target_name}", credential.username)}))).into_response(); - } - } else { - target.credentials.push(ProxyUserCredentials::from(&credential)); + if user.username == credential.username { + // if not an update und username exists -> Error (duplicate username) + if !is_update { + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(json!({"error": format!("Duplicate username {}", &credential.username)})) + ).into_response(); } + + // mark position of user (for update / move) + existing_user_target_index = Some(t_idx); + existing_user_index = Some(u_idx); } } - - let new_api_proxy = Arc::new(api_proxy); - - if new_api_proxy.use_user_db { - if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) { - return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); - } - } else { - let config = app_state.app_config.config.load(); - let backup_dir = config.get_backup_dir(); - let paths = app_state.app_config.paths.load(); - if let Some(err) = crate::api::endpoints::v1_api_config::intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(&*new_api_proxy), paths.api_proxy_file_path.as_str()) { - return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); - } - } - // Udate state after successful save - app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy))); } + + // ---------- if update but no user found -> Error ---------- + if is_update && existing_user_index.is_none() { + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(json!({"error": format!("User {} not found", credential.username)})) + ).into_response(); + } + + + // ---------- create target if new target does not exist ---------- + let target_idx = if let Some(idx) = existing_target_index { + idx + } else { + api_proxy.user.push(TargetUser { + target: target_name.clone(), + credentials: vec![], + }); + api_proxy.user.len() - 1 + }; + + if is_update { + let mut remove_empty_target = false; + // existing_user_index and existing_user_target_index exists at this point + let user_idx = existing_user_index.unwrap(); + let user_target_idx = existing_user_target_index.unwrap(); + + if user_target_idx == target_idx { + // Update + api_proxy.user[user_target_idx].credentials[user_idx] = ProxyUserCredentials::from(&credential); + } else { + // Move: remove from old target and insert into new target + api_proxy.user[user_target_idx].credentials.remove(user_idx); + api_proxy.user[target_idx].credentials.push(ProxyUserCredentials::from(&credential)); + remove_empty_target = api_proxy.user[user_target_idx].credentials.is_empty(); + } + + if remove_empty_target { + api_proxy.user.retain(|t| !t.credentials.is_empty()); + } + + } else { + // new user + api_proxy.user[target_idx].credentials.push(ProxyUserCredentials::from(&credential)); + } + + let new_api_proxy = Arc::new(api_proxy); + + if new_api_proxy.use_user_db { + if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) { + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({"error": err.to_string()})) + ).into_response(); + } + } else { + let config = app_state.app_config.config.load(); + let backup_dir = config.get_backup_dir(); + let paths = app_state.app_config.paths.load(); + if let Some(err) = crate::api::endpoints::v1_api_config::intern_save_config_api_proxy( + backup_dir.as_ref(), + &ApiProxyConfigDto::from(&*new_api_proxy), + paths.api_proxy_file_path.as_str(), + ) { + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({"error": err.to_string()})) + ).into_response(); + } + } + + // Update state after successful save + app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy))); + axum::http::StatusCode::OK.into_response() } diff --git a/backend/src/api/model/streams/custom_video_stream.rs b/backend/src/api/model/streams/custom_video_stream.rs index c8b84dc7b..5edc51eb0 100644 --- a/backend/src/api/model/streams/custom_video_stream.rs +++ b/backend/src/api/model/streams/custom_video_stream.rs @@ -1,19 +1,26 @@ -use crate::api::model::StreamError; -use crate::api::model::TransportStreamBuffer; +use crate::api::model::{StreamError, TransportStreamBuffer}; use bytes::Bytes; use futures::Stream; -use std::pin::Pin; -use std::task::{Context, Poll}; - +use std::{ + pin::Pin, + task::{Context, Poll}, + time::Duration, +}; +use std::future::Future; +use tokio::time::{Sleep, sleep}; pub struct CustomVideoStream { buffer: TransportStreamBuffer, + delay: Option>>, + frame_interval: Duration, } impl CustomVideoStream { pub fn new(buffer: TransportStreamBuffer) -> Self { Self { - buffer + buffer, + delay: None, + frame_interval: Duration::from_millis(40), // ca. 25 FPS } } } @@ -21,7 +28,24 @@ impl CustomVideoStream { impl Stream for CustomVideoStream { type Item = Result; - fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>,) -> Poll> { - Poll::Ready(Some(Ok(self.buffer.next_chunk()))) + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + // is a timer active ? + if let Some(ref mut delay) = self.delay { + if delay.as_mut().poll(cx).is_pending() { + return Poll::Pending; + } + } + + let chunk = self.buffer.next_chunk(); + + // new sleep-timer + self.delay = Some(Box::pin(sleep(self.frame_interval))); + + // Pre-register waker so we get notified even if the consumer doesn't poll again immediately. + if let Some(ref mut delay) = self.delay { + let _ = delay.as_mut().poll(cx); + } + + Poll::Ready(Some(Ok(chunk))) } } diff --git a/backend/src/api/model/streams/transport_stream_buffer.rs b/backend/src/api/model/streams/transport_stream_buffer.rs index ed5b743c7..686957690 100644 --- a/backend/src/api/model/streams/transport_stream_buffer.rs +++ b/backend/src/api/model/streams/transport_stream_buffer.rs @@ -60,7 +60,7 @@ fn encode_pcr(pcr: u64) -> [u8; 6] { ] } -type TsInfoExtraction = (Vec<(usize, Option<(usize, usize, u16)>)>, Vec<(u16, u8)>); +type TsInfoExtraction = (Vec<(usize, Option<(usize, usize, u16)>)>, HashMap); /// Extracts PTS and DTS info from MPEG-TS data. /// Returns a vector of tuples containing: @@ -158,30 +158,27 @@ pub fn extract_pts_dts_indices_with_continuity(ts_data: &[u8]) -> TsInfoExtracti result[first_dts_idx] = (idx, Some((pts, dts, (avg_diff & 0xFFFF) as u16))); } } - let mut vec = Vec::with_capacity(continuity_counters.len()); - vec.extend(continuity_counters.iter().map(|(&k, &v)| (k, v))); - - (result, vec) -} - -/// Replace PTS and DTS timestamps in the TS packet slice -fn replace_pts_dts(packet_slice: &[u8], pts_index: usize, dts_index: usize, new_presentation_ts: u64, new_decoding_ts: u64) -> Vec { - let before_pts = &packet_slice[..pts_index]; - let between_pts_dts = &packet_slice[pts_index + 5..dts_index]; - let after_dts = &packet_slice[dts_index + 5..]; - - let new_presentation_ts_bytes = encode_timestamp(new_presentation_ts); - let new_decoding_ts_bytes = encode_timestamp(new_decoding_ts); - - let mut new_packet = Vec::with_capacity(packet_slice.len()); - new_packet.extend_from_slice(before_pts); - new_packet.extend_from_slice(&new_presentation_ts_bytes); - new_packet.extend_from_slice(between_pts_dts); - new_packet.extend_from_slice(&new_decoding_ts_bytes); - new_packet.extend_from_slice(after_dts); - - new_packet + (result, continuity_counters) } +// +// /// Replace PTS and DTS timestamps in the TS packet slice +// fn replace_pts_dts(packet_slice: &[u8], pts_index: usize, dts_index: usize, new_presentation_ts: u64, new_decoding_ts: u64) -> Vec { +// let before_pts = &packet_slice[..pts_index]; +// let between_pts_dts = &packet_slice[pts_index + 5..dts_index]; +// let after_dts = &packet_slice[dts_index + 5..]; +// +// let new_presentation_ts_bytes = encode_timestamp(new_presentation_ts); +// let new_decoding_ts_bytes = encode_timestamp(new_decoding_ts); +// +// let mut new_packet = Vec::with_capacity(packet_slice.len()); +// new_packet.extend_from_slice(before_pts); +// new_packet.extend_from_slice(&new_presentation_ts_bytes); +// new_packet.extend_from_slice(between_pts_dts); +// new_packet.extend_from_slice(&new_decoding_ts_bytes); +// new_packet.extend_from_slice(after_dts); +// +// new_packet +// } /// Finds TS alignment by checking for 0x47 sync byte every 188 bytes fn find_ts_alignment(buf: &[u8]) -> Option { @@ -238,8 +235,8 @@ pub struct TransportStreamBuffer { timestamp_offset: u64, length: usize, stream_duration_90khz: u64, // Duration in 90kHz units - initial_continuity_counters: Arc>, - continuity_counters: Vec<(u16,u8)>, + initial_continuity_counters: Arc>, + continuity_counters: HashMap, } impl Clone for TransportStreamBuffer { @@ -289,77 +286,72 @@ impl TransportStreamBuffer { } } + /// Returns next chunks with adjusted PTS/DTS and PCR /// Returns next chunks with adjusted PTS/DTS and PCR pub fn next_chunk(&mut self) -> Bytes { let mut bytes = BytesMut::with_capacity(CHUNK_SIZE); - // we send this amount of packets in one chunk + + // Change continuity_counters to HashMap instead of Vec<(u16,u8)> + // to avoid O(n) searches per PID. (Change type in struct definition) + // continuity_counters: HashMap, + let mut packets_remaining = PACKET_COUNT; while packets_remaining > 0 { if self.current_pos >= self.length { // Loop back and update timestamp offset self.current_pos = 0; - - let new_offset = (self.timestamp_offset + self.stream_duration_90khz) % MAX_PTS_DTS; - self.timestamp_offset = new_offset; - + self.timestamp_offset = (self.timestamp_offset + self.stream_duration_90khz) % MAX_PTS_DTS; self.current_dts = 0; } - let current_pos = self.current_pos; - let (packet_start, pts_dts_maybe) = self.packet_indices[current_pos]; + let (packet_start, pts_dts_maybe) = self.packet_indices[self.current_pos]; let packet = &self.buffer[packet_start..packet_start + TS_PACKET_SIZE]; - let mut new_packet = packet.to_vec(); + // Reuse stack array instead of heap allocation + let mut new_packet = [0u8; TS_PACKET_SIZE]; + new_packet.copy_from_slice(packet); - // update continuity counter - let pid = (u16::from(new_packet[1] & 0x1F) << 8) | u16::from(new_packet[2]); - // let counter = &mut self.continuity_counters[pid as usize]; + // ---- Continuity Counter ---- + // let pid = (u16::from(new_packet[1] & 0x1F) << 8) | u16::from(new_packet[2]); + // let counter = self.continuity_counters.entry(pid).or_insert(0); // new_packet[3] = (new_packet[3] & 0xF0) | (*counter & 0x0F); // *counter = (*counter + 1) % 16; - - // Find the entry with this PID (mutable), or insert a new entry if it doesn't exist - if let Some((_pid, counter)) = self.continuity_counters.iter_mut().find(|(p, _)| *p == pid) { - new_packet[3] = (new_packet[3] & 0xF0) | (*counter & 0x0F); + let pid = (u16::from(new_packet[1] & 0x1F) << 8) | u16::from(new_packet[2]); + let afc = (new_packet[3] >> 4) & 0b11; + let has_payload = (afc & 0x01) != 0; + let counter = self.continuity_counters.entry(pid).or_insert(0); + new_packet[3] = (new_packet[3] & 0xF0) | (*counter & 0x0F); + if pid != 0x1FFF && has_payload { *counter = (*counter + 1) % 16; - } else { - // This should not happen because we scan the entire pids. - // PID not present yet, insert new entry with counter = 0 - self.continuity_counters.push((pid, 1)); // Start at 1, since we immediately use the counter - new_packet[3] &= 0xF0; } - // adjust PCR based on the original PCR, then add the offset + // ---- PCR update ---- let afc = (new_packet[3] >> 4) & 0b11; if afc == 2 || afc == 3 { let adaptation_len = new_packet[4] as usize; - if adaptation_len > 0 { + if adaptation_len >= 7 { let flags = new_packet[5]; if (flags & ADAPTATION_FIELD_FLAG_PCR) != 0 { let pcr_pos = 6; - if new_packet.len() >= pcr_pos + 6 { - // read original PCR - let orig_pcr = decode_pcr(&new_packet[pcr_pos..pcr_pos + 6]); - // Apply PCR offset; PCR runs at 27 MHz, so multiply by 300 to convert from 90 kHz to 27 MHz - let offset = self.timestamp_offset * 300; - let new_pcr = (orig_pcr + offset) % MAX_PCR; - let pcr_bytes = encode_pcr(new_pcr); - new_packet[pcr_pos..pcr_pos + 6].copy_from_slice(&pcr_bytes); - } + let orig_pcr = decode_pcr(&new_packet[pcr_pos..pcr_pos + 6]); + let new_pcr = (orig_pcr + self.timestamp_offset * 300) % MAX_PCR; + let pcr_bytes = encode_pcr(new_pcr); + new_packet[pcr_pos..pcr_pos + 6].copy_from_slice(&pcr_bytes); } } } - // adjust PTS/DTS + // ---- PTS/DTS update ---- if let Some((pts_offset, dts_offset, _diff)) = pts_dts_maybe { - let orig_dts = decode_timestamp(&new_packet[dts_offset..dts_offset + 5]); - let new_decoding_ts = (orig_dts + self.timestamp_offset) % MAX_PTS_DTS; + let decoding_ts_orig = decode_timestamp(&new_packet[dts_offset..dts_offset + 5]); + let presentation_ts_orig = decode_timestamp(&new_packet[pts_offset..pts_offset + 5]); + let new_decoding_ts = (decoding_ts_orig + self.timestamp_offset) % MAX_PTS_DTS; + let new_presentation_ts = (presentation_ts_orig + self.timestamp_offset) % MAX_PTS_DTS; - let orig_presentation_ts = decode_timestamp(&new_packet[pts_offset..pts_offset + 5]); - let new_presentation_ts = (orig_presentation_ts + self.timestamp_offset) % MAX_PTS_DTS; - - let replaced = replace_pts_dts(&new_packet, pts_offset, dts_offset, new_presentation_ts, new_decoding_ts); - new_packet = replaced; + // Replace in-place, no new Vec allocation + new_packet[pts_offset..pts_offset + 5].copy_from_slice(&encode_timestamp(new_decoding_ts)); + new_packet[dts_offset..dts_offset + 5].copy_from_slice(&encode_timestamp(new_presentation_ts)); } bytes.extend_from_slice(&new_packet); diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index 423cc3571..274fe8a63 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -71,7 +71,7 @@ --text-color: #ffffff; --modest-text-color: #a7abb4; --background-color: #0a0b10; - --border-color: rgba(90, 140, 255, 0.25); + --border-color: rgba(60, 100, 195, 0.25); --input-background-color: rgba(255, 255, 255, 0); --input-border-color: var(--border-color); --input-focus-border-color: rgba(80, 150, 255, 0.8); diff --git a/frontend/scss/app/components/_select.scss b/frontend/scss/app/components/_select.scss index 80baa0941..f43712aab 100644 --- a/frontend/scss/app/components/_select.scss +++ b/frontend/scss/app/components/_select.scss @@ -12,7 +12,7 @@ align-items: center; overflow: hidden; box-sizing: border-box; - padding-left: var(--padding-mini); + padding-right: var(--padding-mini); border: 1px solid var(--input-border-color); border-radius: var(--border-radius); background-color: var(--input-background-color); diff --git a/frontend/src/app/components/userlist/edit.rs b/frontend/src/app/components/userlist/edit.rs index d93417da6..499692cc5 100644 --- a/frontend/src/app/components/userlist/edit.rs +++ b/frontend/src/app/components/userlist/edit.rs @@ -37,7 +37,7 @@ pub fn UserEdit() -> Html { } }); - let handle_back = { + let handle_cancel = { let userlist_ctx = userlist_ctx.clone(); Callback::from(move |_| { userlist_ctx.active_page.set(UserlistPage::List); @@ -45,6 +45,13 @@ pub fn UserEdit() -> Html { }) }; + let handle_back = { + let handle_cancel = handle_cancel.clone(); + Callback::from(move |_| { + handle_cancel.emit(()); + }) + }; + let handle_user_save = { let userlist = userlist_ctx.clone(); let handleback = handle_back.clone(); @@ -56,16 +63,22 @@ pub fn UserEdit() -> Html { let userlist = userlist.clone(); let translate = translate.clone(); spawn_local(async move { - match if is_update { services.user.update_user(target.clone(), user.clone()).await } else { services.user.create_user(target.clone(), user.clone()).await } { + let save_result = if is_update { + services.user.update_user(target.clone(), user.clone()).await + } else { + services.user.create_user(target.clone(), user.clone()).await + }; + match save_result { Ok(()) => { let new_user = Rc::new(TargetUser {target: target.clone(), credentials: Rc::new(user.clone()) }); let new_user_list = if let Some(user_list) = userlist.users.as_ref() { - let mut new_list: Vec> = user_list.iter().map(|target_user| { - let mut cloned = target_user.as_ref().clone(); - if is_update && cloned.target == target && cloned.credentials.username == user.username { - cloned.credentials = Rc::new(user.clone()); - } - Rc::new(cloned) + let mut new_list: Vec> = user_list.iter().map(|target_user| { + let mut cloned_target = target_user.as_ref().clone(); + if is_update && cloned_target.credentials.username == user.username { + cloned_target.credentials = Rc::new(user.clone()); + cloned_target.target = target.clone(); + } + Rc::new(cloned_target) }).collect(); if !is_update { @@ -98,7 +111,7 @@ pub fn UserEdit() -> Html {
- +
diff --git a/frontend/src/app/components/userlist/proxy_user_credentials_form.rs b/frontend/src/app/components/userlist/proxy_user_credentials_form.rs index 9dd455840..147f6e532 100644 --- a/frontend/src/app/components/userlist/proxy_user_credentials_form.rs +++ b/frontend/src/app/components/userlist/proxy_user_credentials_form.rs @@ -1,5 +1,6 @@ use std::rc::Rc; use chrono::{Duration, Utc}; +use log::debug; use yew::prelude::*; use yew_i18n::use_translation; use shared::model::{ApiProxyServerInfoDto, ConfigTargetDto, ProxyType, ProxyUserCredentialsDto, ProxyUserStatus}; @@ -39,6 +40,7 @@ pub struct ProxyUserCredentialsFormProps { pub targets: Rc>>, pub server: Rc>, pub on_save: Callback<(bool, String, ProxyUserCredentialsDto)>, + pub on_cancel: Callback<()>, } #[function_component] @@ -116,6 +118,13 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html { ); } + let handle_cancel = { + let oncancel = props.on_cancel.clone(); + Callback::from(move |_| { + oncancel.emit(()); + }) + }; + let handle_save_user = { let user = form_state.clone(); let original = props.user.clone(); @@ -125,8 +134,11 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html { let onsave = props.on_save.clone(); let is_update = update.clone(); Callback::from(move |_| { + let nothing_to_save = || services.toastr.warning(translate_clone.t("MESSAGES.SAVE.USER.NOTHING_TO_SAVE")); if let Some(target_name) = (*target).as_ref().cloned() { - if user.modified() { + let original_target = original.as_ref().map(|u| u.target.clone()).unwrap_or_default(); + let target_changed = target_name != original_target; + if target_changed || user.modified() { let user = user.data(); if let Err(err) = user.validate() { services.toastr.error(err.to_string()); @@ -134,14 +146,16 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html { match original.as_ref().map(|t| t.credentials.clone()) { None => onsave.emit((*is_update, target_name, user.clone())), Some(original_user) => { - if &(*original_user) != user { + if target_changed || &(*original_user) != user { onsave.emit((*is_update, target_name, user.clone())); + } else { + nothing_to_save(); } } }; } } else { - services.toastr.warning(translate_clone.t("MESSAGES.SAVE.USER.NOTHING_TO_SAVE")); + nothing_to_save(); } } else { services.toastr.error(translate_clone.t("MESSAGES.SAVE.USER.TARGET_NOT_SELECTED")); @@ -227,6 +241,10 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html { icon="Save" title={ translate.t("LABEL.SAVE")} onclick={handle_save_user}> + } diff --git a/shared/src/utils/string_utils.rs b/shared/src/utils/string_utils.rs index 1fd1dcb96..4844bfbcc 100644 --- a/shared/src/utils/string_utils.rs +++ b/shared/src/utils/string_utils.rs @@ -99,6 +99,13 @@ pub fn truncate_string(s: &str, max_len: usize) -> String { } } +pub fn mask_credentials(s: &str) -> String { + match s.chars().next() { + Some(first) => format!("{}...", first), + None => "...".to_string(), + } +} + #[cfg(test)] mod test { use std::collections::HashSet;