diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 1cf8b24c265..09d3d15e897 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -338,7 +338,7 @@ checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bitwarden-russh" version = "0.1.0" -source = "git+https://github.com/bitwarden/bitwarden-russh.git?rev=b4e7f2fedbe3df8c35545feb000176d3e7b2bc32#b4e7f2fedbe3df8c35545feb000176d3e7b2bc32" +source = "git+https://github.com/bitwarden/bitwarden-russh.git?rev=23b50e3bbe6d56ef19ab0e98e8bb1462cb6d77ae#23b50e3bbe6d56ef19ab0e98e8bb1462cb6d77ae" dependencies = [ "anyhow", "byteorder", @@ -584,6 +584,25 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.20" @@ -773,6 +792,7 @@ dependencies = [ "sha2", "ssh-encoding", "ssh-key", + "sysinfo", "thiserror", "tokio", "tokio-stream", @@ -903,6 +923,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + [[package]] name = "embed_plist" version = "1.2.2" @@ -1490,6 +1516,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "num" version = "0.4.3" @@ -2051,6 +2086,26 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "recvmsg" version = "1.0.0" @@ -2453,6 +2508,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sysinfo" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b5ae3f4f7d64646c46c4cae4e3f01d1c5d255c7406fdd7c7f999a94e488791" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + [[package]] name = "tempfile" version = "3.14.0" diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index adfdd818a17..597a082b231 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -49,7 +49,7 @@ ssh-key = { version = "=0.6.7", default-features = false, features = [ "rsa", "getrandom", ] } -bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "b4e7f2fedbe3df8c35545feb000176d3e7b2bc32" } +bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "23b50e3bbe6d56ef19ab0e98e8bb1462cb6d77ae" } tokio = { version = "=1.41.1", features = ["io-util", "sync", "macros", "net"] } tokio-stream = { version = "=0.1.15", features = ["net"] } tokio-util = { version = "=0.7.12", features = ["codec"] } @@ -59,6 +59,7 @@ rand_chacha = "=0.3.1" pkcs8 = { version = "=0.10.2", features = ["alloc", "encryption", "pem"] } rsa = "=0.9.6" ed25519 = { version = "=2.2.3", features = ["pkcs8"] } +sysinfo = { version = "0.32.0", features = ["windows"] } [target.'cfg(windows)'.dependencies] widestring = { version = "=1.1.0", optional = true } @@ -72,6 +73,7 @@ windows = { version = "=0.58.0", features = [ "Win32_System_WinRT", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", + "Win32_System_Pipes", ], optional = true } [target.'cfg(windows)'.dev-dependencies] diff --git a/apps/desktop/desktop_native/core/src/biometric/windows.rs b/apps/desktop/desktop_native/core/src/biometric/windows.rs index fcc5b95cc4a..d17ea752878 100644 --- a/apps/desktop/desktop_native/core/src/biometric/windows.rs +++ b/apps/desktop/desktop_native/core/src/biometric/windows.rs @@ -310,12 +310,16 @@ mod tests { os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), }; - crate::password::set_password(test, test, secret).await.unwrap(); + crate::password::set_password(test, test, secret) + .await + .unwrap(); let result = ::get_biometric_secret(test, test, Some(key_material)) .await .unwrap(); - crate::password::delete_password("test", "test").await.unwrap(); + crate::password::delete_password("test", "test") + .await + .unwrap(); assert_eq!(result, secret); } @@ -328,19 +332,24 @@ mod tests { os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), }; - crate::password::set_password(test, test, &secret.to_string()).await.unwrap(); + crate::password::set_password(test, test, &secret.to_string()) + .await + .unwrap(); let result = ::get_biometric_secret(test, test, Some(key_material)) .await .unwrap(); - crate::password::delete_password("test", "test").await.unwrap(); + crate::password::delete_password("test", "test") + .await + .unwrap(); assert_eq!(result, "secret"); } #[tokio::test] async fn set_biometric_secret_requires_key() { - let result = ::set_biometric_secret("", "", "", None, "").await; + let result = + ::set_biometric_secret("", "", "", None, "").await; assert!(result.is_err()); assert_eq!( result.unwrap_err().to_string(), diff --git a/apps/desktop/desktop_native/core/src/password/macos.rs b/apps/desktop/desktop_native/core/src/password/macos.rs index c911a0d2430..b69854905d9 100644 --- a/apps/desktop/desktop_native/core/src/password/macos.rs +++ b/apps/desktop/desktop_native/core/src/password/macos.rs @@ -28,12 +28,18 @@ mod tests { #[tokio::test] async fn test() { - set_password("BitwardenTest", "BitwardenTest", "Random").await.unwrap(); + set_password("BitwardenTest", "BitwardenTest", "Random") + .await + .unwrap(); assert_eq!( "Random", - get_password("BitwardenTest", "BitwardenTest").await.unwrap() + get_password("BitwardenTest", "BitwardenTest") + .await + .unwrap() ); - delete_password("BitwardenTest", "BitwardenTest").await.unwrap(); + delete_password("BitwardenTest", "BitwardenTest") + .await + .unwrap(); // Ensure password is deleted match get_password("BitwardenTest", "BitwardenTest").await { diff --git a/apps/desktop/desktop_native/core/src/password/unix.rs b/apps/desktop/desktop_native/core/src/password/unix.rs index 20a79625efb..f73b41de8c1 100644 --- a/apps/desktop/desktop_native/core/src/password/unix.rs +++ b/apps/desktop/desktop_native/core/src/password/unix.rs @@ -5,9 +5,7 @@ use std::collections::HashMap; pub async fn get_password(service: &str, account: &str) -> Result { match get_password_new(service, account).await { Ok(res) => Ok(res), - Err(_) => { - get_password_legacy(service, account).await - } + Err(_) => get_password_legacy(service, account).await, } } @@ -20,8 +18,8 @@ async fn get_password_new(service: &str, account: &str) -> Result { Some(res) => { let secret = res.secret().await?; Ok(String::from_utf8(secret.to_vec())?) - }, - None => Err(anyhow!("no result")) + } + None => Err(anyhow!("no result")), } } @@ -37,20 +35,30 @@ async fn get_password_legacy(service: &str, account: &str) -> Result { match res { Some(res) => { let secret = res.secret().await?; - println!("deleting legacy secret service entry {} {}", service, account); + println!( + "deleting legacy secret service entry {} {}", + service, account + ); keyring.delete(&attributes).await?; let secret_string = String::from_utf8(secret.to_vec())?; set_password(service, account, &secret_string).await?; Ok(secret_string) - }, - None => Err(anyhow!("no result")) + } + None => Err(anyhow!("no result")), } } pub async fn set_password(service: &str, account: &str, password: &str) -> Result<()> { let keyring = oo7::Keyring::new().await?; let attributes = HashMap::from([("service", service), ("account", account)]); - keyring.create_item("org.freedesktop.Secret.Generic", &attributes, password, true).await?; + keyring + .create_item( + "org.freedesktop.Secret.Generic", + &attributes, + password, + true, + ) + .await?; Ok(()) } @@ -74,22 +82,25 @@ mod tests { #[tokio::test] async fn test() { - set_password("BitwardenTest", "BitwardenTest", "Random").await.unwrap(); + set_password("BitwardenTest", "BitwardenTest", "Random") + .await + .unwrap(); assert_eq!( "Random", - get_password("BitwardenTest", "BitwardenTest").await.unwrap() + get_password("BitwardenTest", "BitwardenTest") + .await + .unwrap() ); - delete_password("BitwardenTest", "BitwardenTest").await.unwrap(); + delete_password("BitwardenTest", "BitwardenTest") + .await + .unwrap(); // Ensure password is deleted match get_password("BitwardenTest", "BitwardenTest").await { Ok(_) => { panic!("Got a result") } - Err(e) => assert_eq!( - "no result", - e.to_string() - ), + Err(e) => assert_eq!("no result", e.to_string()), } } @@ -97,10 +108,7 @@ mod tests { async fn test_error_no_password() { match get_password("Unknown", "Unknown").await { Ok(_) => panic!("Got a result"), - Err(e) => assert_eq!( - "no result", - e.to_string() - ), + Err(e) => assert_eq!("no result", e.to_string()), } } } diff --git a/apps/desktop/desktop_native/core/src/password/windows.rs b/apps/desktop/desktop_native/core/src/password/windows.rs index 873e717ac8b..2a66640286f 100644 --- a/apps/desktop/desktop_native/core/src/password/windows.rs +++ b/apps/desktop/desktop_native/core/src/password/windows.rs @@ -112,12 +112,18 @@ mod tests { #[tokio::test] async fn test() { - set_password("BitwardenTest", "BitwardenTest", "Random").await.unwrap(); + set_password("BitwardenTest", "BitwardenTest", "Random") + .await + .unwrap(); assert_eq!( "Random", - get_password("BitwardenTest", "BitwardenTest").await.unwrap() + get_password("BitwardenTest", "BitwardenTest") + .await + .unwrap() ); - delete_password("BitwardenTest", "BitwardenTest").await.unwrap(); + delete_password("BitwardenTest", "BitwardenTest") + .await + .unwrap(); // Ensure password is deleted match get_password("BitwardenTest", "BitwardenTest").await { diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index 9d04ea87ccb..82b90c7bff9 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -1,4 +1,7 @@ -use std::sync::Arc; +use std::sync::{ + atomic::{AtomicBool, AtomicU32}, + Arc, +}; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -10,34 +13,52 @@ use bitwarden_russh::ssh_agent::{self, Key}; #[cfg_attr(target_os = "linux", path = "unix.rs")] mod platform_ssh_agent; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod peercred_unix_listener_stream; + pub mod generator; pub mod importer; - +pub mod peerinfo; #[derive(Clone)] pub struct BitwardenDesktopAgent { keystore: ssh_agent::KeyStore, cancellation_token: CancellationToken, - show_ui_request_tx: tokio::sync::mpsc::Sender<(u32, (String, bool))>, + show_ui_request_tx: tokio::sync::mpsc::Sender, get_ui_response_rx: Arc>>, - request_id: Arc>, + request_id: Arc, /// before first unlock, or after account switching, listing keys should require an unlock to get a list of public keys - needs_unlock: Arc>, - is_running: Arc>, + needs_unlock: Arc, + is_running: Arc, } -impl ssh_agent::Agent for BitwardenDesktopAgent { - async fn confirm(&self, ssh_key: Key) -> bool { - if !*self.is_running.lock().await { +pub struct SshAgentUIRequest { + pub request_id: u32, + pub cipher_id: Option, + pub process_name: String, + pub is_list: bool, +} + +impl ssh_agent::Agent for BitwardenDesktopAgent { + async fn confirm(&self, ssh_key: Key, info: &peerinfo::models::PeerInfo) -> bool { + if !self.is_running() { println!("[BitwardenDesktopAgent] Agent is not running, but tried to call confirm"); return false; } let request_id = self.get_request_id().await; + println!( + "[SSH Agent] Confirming request from application: {}", + info.process_name() + ); let mut rx_channel = self.get_ui_response_rx.lock().await.resubscribe(); - let message = (request_id, (ssh_key.cipher_uuid.clone(), false)); self.show_ui_request_tx - .send(message) + .send(SshAgentUIRequest { + request_id, + cipher_id: Some(ssh_key.cipher_uuid.clone()), + process_name: info.process_name().to_string(), + is_list: false, + }) .await .expect("Should send request to ui"); while let Ok((id, response)) = rx_channel.recv().await { @@ -48,15 +69,20 @@ impl ssh_agent::Agent for BitwardenDesktopAgent { false } - async fn can_list(&self) -> bool { - if !*self.needs_unlock.lock().await{ + async fn can_list(&self, info: &peerinfo::models::PeerInfo) -> bool { + if !self.needs_unlock.load(std::sync::atomic::Ordering::Relaxed) { return true; } let request_id = self.get_request_id().await; let mut rx_channel = self.get_ui_response_rx.lock().await.resubscribe(); - let message = (request_id, ("".to_string(), true)); + let message = SshAgentUIRequest { + request_id, + cipher_id: None, + process_name: info.process_name().to_string(), + is_list: true, + }; self.show_ui_request_tx .send(message) .await @@ -72,13 +98,13 @@ impl ssh_agent::Agent for BitwardenDesktopAgent { impl BitwardenDesktopAgent { pub fn stop(&self) { - if !*self.is_running.blocking_lock() { + if !self.is_running() { println!("[BitwardenDesktopAgent] Tried to stop agent while it is not running"); return; } - *self.is_running.blocking_lock() = false; - self.cancellation_token.cancel(); + self.is_running + .store(false, std::sync::atomic::Ordering::Relaxed); self.keystore .0 .write() @@ -90,7 +116,7 @@ impl BitwardenDesktopAgent { &mut self, new_keys: Vec<(String, String, String)>, ) -> Result<(), anyhow::Error> { - if !*self.is_running.blocking_lock() { + if !self.is_running() { return Err(anyhow::anyhow!( "[BitwardenDesktopAgent] Tried to set keys while agent is not running" )); @@ -99,7 +125,8 @@ impl BitwardenDesktopAgent { let keystore = &mut self.keystore; keystore.0.write().expect("RwLock is not poisoned").clear(); - *self.needs_unlock.blocking_lock() = false; + self.needs_unlock + .store(true, std::sync::atomic::Ordering::Relaxed); for (key, name, cipher_id) in new_keys.iter() { match parse_key_safe(&key) { @@ -127,7 +154,7 @@ impl BitwardenDesktopAgent { } pub fn lock(&mut self) -> Result<(), anyhow::Error> { - if !*self.is_running.blocking_lock() { + if !self.is_running() { return Err(anyhow::anyhow!( "[BitwardenDesktopAgent] Tried to lock agent, but it is not running" )); @@ -148,24 +175,26 @@ impl BitwardenDesktopAgent { pub fn clear_keys(&mut self) -> Result<(), anyhow::Error> { let keystore = &mut self.keystore; keystore.0.write().expect("RwLock is not poisoned").clear(); - *self.needs_unlock.blocking_lock() = true; + self.needs_unlock + .store(true, std::sync::atomic::Ordering::Relaxed); Ok(()) } async fn get_request_id(&self) -> u32 { - if !*self.is_running.lock().await { + if !self.is_running() { println!("[BitwardenDesktopAgent] Agent is not running, but tried to get request id"); return 0; } - let mut request_id = self.request_id.lock().await; - *request_id += 1; - *request_id + let request_id = self + .request_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + request_id } - pub fn is_running(self) -> bool { - return self.is_running.blocking_lock().clone(); + pub fn is_running(&self) -> bool { + self.is_running.load(std::sync::atomic::Ordering::Relaxed) } } diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs b/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs index 49c3aa80612..1358abe32e0 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs @@ -1,23 +1,32 @@ -use std::{ - io, pin::Pin, sync::Arc, task::{Context, Poll} -}; - use futures::Stream; +use std::os::windows::prelude::AsRawHandle as _; +use std::{ + io, + pin::Pin, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + task::{Context, Poll}, +}; use tokio::{ net::windows::named_pipe::{NamedPipeServer, ServerOptions}, select, }; use tokio_util::sync::CancellationToken; +use windows::Win32::{Foundation::HANDLE, System::Pipes::GetNamedPipeClientProcessId}; + +use crate::ssh_agent::peerinfo::{self, models::PeerInfo}; const PIPE_NAME: &str = r"\\.\pipe\openssh-ssh-agent"; #[pin_project::pin_project] pub struct NamedPipeServerStream { - rx: tokio::sync::mpsc::Receiver, + rx: tokio::sync::mpsc::Receiver<(NamedPipeServer, PeerInfo)>, } impl NamedPipeServerStream { - pub fn new(cancellation_token: CancellationToken, is_running: Arc>) -> Self { + pub fn new(cancellation_token: CancellationToken, is_running: Arc) -> Self { let (tx, rx) = tokio::sync::mpsc::channel(16); tokio::spawn(async move { println!( @@ -30,7 +39,7 @@ impl NamedPipeServerStream { println!("[SSH Agent Native Module] Encountered an error creating the first pipe. The system's openssh service must likely be disabled"); println!("[SSH Agent Natvie Module] error: {}", err); cancellation_token.cancel(); - *is_running.lock().await = false; + is_running.store(false, Ordering::Relaxed); return; } }; @@ -43,14 +52,32 @@ impl NamedPipeServerStream { } _ = listener.connect() => { println!("[SSH Agent Native Module] Incoming connection"); - tx.send(listener).await.unwrap(); + let handle = HANDLE(listener.as_raw_handle()); + let mut pid = 0; + unsafe { + if let Err(e) = GetNamedPipeClientProcessId(handle, &mut pid) { + println!("Error getting named pipe client process id {}", e); + continue + } + }; + + let peer_info = peerinfo::gather::get_peer_info(pid as u32); + let peer_info = match peer_info { + Err(err) => { + println!("Failed getting process info for pid {} {}", pid, err); + continue + }, + Ok(info) => info, + }; + + tx.send((listener, peer_info)).await.unwrap(); listener = match ServerOptions::new().create(PIPE_NAME) { Ok(pipe) => pipe, Err(err) => { println!("[SSH Agent Native Module] Encountered an error creating a new pipe {}", err); cancellation_token.cancel(); - *is_running.lock().await = false; + is_running.store(false, Ordering::Relaxed); return; } }; @@ -63,12 +90,12 @@ impl NamedPipeServerStream { } impl Stream for NamedPipeServerStream { - type Item = io::Result; + type Item = io::Result<(NamedPipeServer, PeerInfo)>; fn poll_next( self: Pin<&mut Self>, cx: &mut Context<'_>, - ) -> Poll>> { + ) -> Poll>> { let this = self.project(); this.rx.poll_recv(cx).map(|v| v.map(Ok)) diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs b/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs new file mode 100644 index 00000000000..f0114fc08da --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs @@ -0,0 +1,72 @@ +use futures::Stream; +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::net::{UnixListener, UnixStream}; + +use super::peerinfo; +use super::peerinfo::models::PeerInfo; + +#[derive(Debug)] +pub struct PeercredUnixListenerStream { + inner: UnixListener, +} + +impl PeercredUnixListenerStream { + pub fn new(listener: UnixListener) -> Self { + Self { inner: listener } + } +} + +impl Stream for PeercredUnixListenerStream { + type Item = io::Result<(UnixStream, PeerInfo)>; + + fn poll_next( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>> { + match self.inner.poll_accept(cx) { + Poll::Ready(Ok((stream, _))) => { + let pid = match stream.peer_cred() { + Ok(peer) => match peer.pid() { + Some(pid) => pid, + None => { + return Poll::Ready(Some(Err(io::Error::new( + io::ErrorKind::Other, + "Failed to get peer PID", + )))); + } + }, + Err(err) => { + return Poll::Ready(Some(Err(io::Error::new( + io::ErrorKind::Other, + format!("Failed to get peer credentials: {}", err), + )))); + } + }; + let peer_info = peerinfo::gather::get_peer_info(pid as u32); + match peer_info { + Ok(info) => Poll::Ready(Some(Ok((stream, info)))), + Err(err) => Poll::Ready(Some(Err(io::Error::new( + io::ErrorKind::Other, + format!("Failed to get peer info: {}", err), + )))), + } + } + Poll::Ready(Err(err)) => Poll::Ready(Some(Err(err))), + Poll::Pending => Poll::Pending, + } + } +} + +impl AsRef for PeercredUnixListenerStream { + fn as_ref(&self) -> &UnixListener { + &self.inner + } +} + +impl AsMut for PeercredUnixListenerStream { + fn as_mut(&mut self) -> &mut UnixListener { + &mut self.inner + } +} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/gather.rs b/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/gather.rs new file mode 100644 index 00000000000..699203d613d --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/gather.rs @@ -0,0 +1,23 @@ +use sysinfo::{Pid, System}; + +use super::models::PeerInfo; + +pub fn get_peer_info(peer_pid: u32) -> Result { + let s = System::new_all(); + if let Some(process) = s.process(Pid::from_u32(peer_pid)) { + let peer_process_name = match process.name().to_str() { + Some(name) => name.to_string(), + None => { + return Err("Failed to get process name".to_string()); + } + }; + + return Ok(PeerInfo::new( + peer_pid, + process.pid().as_u32(), + peer_process_name, + )); + } + + Err("Failed to get process".to_string()) +} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/mod.rs new file mode 100644 index 00000000000..fb12aa66e09 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/mod.rs @@ -0,0 +1,2 @@ +pub mod gather; +pub mod models; diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs b/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs new file mode 100644 index 00000000000..823d912883e --- /dev/null +++ b/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs @@ -0,0 +1,32 @@ +/** +* Peerinfo represents the information of a peer process connecting over a socket. +* This can be later extended to include more information (icon, app name) for the corresponding application. +*/ +#[derive(Debug)] +pub struct PeerInfo { + uid: u32, + pid: u32, + process_name: String, +} + +impl PeerInfo { + pub fn new(uid: u32, pid: u32, process_name: String) -> Self { + Self { + uid, + pid, + process_name, + } + } + + pub fn uid(&self) -> u32 { + self.uid + } + + pub fn pid(&self) -> u32 { + self.pid + } + + pub fn process_name(&self) -> &str { + &self.process_name + } +} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs index ed2fe9ffab1..a74c1205b57 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs @@ -2,7 +2,10 @@ use std::{ collections::HashMap, fs, os::unix::fs::PermissionsExt, - sync::{Arc, RwLock}, + sync::{ + atomic::{AtomicBool, AtomicU32}, + Arc, RwLock, + }, }; use bitwarden_russh::ssh_agent; @@ -10,11 +13,13 @@ use homedir::my_home; use tokio::{net::UnixListener, sync::Mutex}; use tokio_util::sync::CancellationToken; -use super::BitwardenDesktopAgent; +use crate::ssh_agent::peercred_unix_listener_stream::PeercredUnixListenerStream; + +use super::{BitwardenDesktopAgent, SshAgentUIRequest}; impl BitwardenDesktopAgent { pub async fn start_server( - auth_request_tx: tokio::sync::mpsc::Sender<(u32, (String, bool))>, + auth_request_tx: tokio::sync::mpsc::Sender, auth_response_rx: Arc>>, ) -> Result { let agent = BitwardenDesktopAgent { @@ -22,9 +27,9 @@ impl BitwardenDesktopAgent { cancellation_token: CancellationToken::new(), show_ui_request_tx: auth_request_tx, get_ui_response_rx: auth_response_rx, - request_id: Arc::new(tokio::sync::Mutex::new(0)), - needs_unlock: Arc::new(tokio::sync::Mutex::new(true)), - is_running: Arc::new(tokio::sync::Mutex::new(false)), + request_id: Arc::new(AtomicU32::new(0)), + needs_unlock: Arc::new(AtomicBool::new(false)), + is_running: Arc::new(AtomicBool::new(false)), }; let cloned_agent_state = agent.clone(); tokio::spawn(async move { @@ -75,18 +80,23 @@ impl BitwardenDesktopAgent { return; } - let wrapper = tokio_stream::wrappers::UnixListenerStream::new(listener); + let stream = PeercredUnixListenerStream::new(listener); + let cloned_keystore = cloned_agent_state.keystore.clone(); let cloned_cancellation_token = cloned_agent_state.cancellation_token.clone(); - *cloned_agent_state.is_running.lock().await = true; + cloned_agent_state + .is_running + .store(true, std::sync::atomic::Ordering::Relaxed); let _ = ssh_agent::serve( - wrapper, + stream, cloned_agent_state.clone(), cloned_keystore, cloned_cancellation_token, ) .await; - *cloned_agent_state.is_running.lock().await = false; + cloned_agent_state + .is_running + .store(false, std::sync::atomic::Ordering::Relaxed); println!("[SSH Agent Native Module] SSH Agent server exited"); } Err(e) => { diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs b/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs index 6a99b7cfb00..bc63ef552b7 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs @@ -3,16 +3,19 @@ pub mod named_pipe_listener_stream; use std::{ collections::HashMap, - sync::{Arc, RwLock}, + sync::{ + atomic::{AtomicBool, AtomicU32}, + Arc, RwLock, + }, }; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; -use super::BitwardenDesktopAgent; +use super::{BitwardenDesktopAgent, SshAgentUIRequest}; impl BitwardenDesktopAgent { pub async fn start_server( - auth_request_tx: tokio::sync::mpsc::Sender<(u32, (String, bool))>, + auth_request_tx: tokio::sync::mpsc::Sender, auth_response_rx: Arc>>, ) -> Result { let agent_state = BitwardenDesktopAgent { @@ -20,9 +23,9 @@ impl BitwardenDesktopAgent { show_ui_request_tx: auth_request_tx, get_ui_response_rx: auth_response_rx, cancellation_token: CancellationToken::new(), - request_id: Arc::new(tokio::sync::Mutex::new(0)), - needs_unlock: Arc::new(tokio::sync::Mutex::new(true)), - is_running: Arc::new(tokio::sync::Mutex::new(true)), + request_id: Arc::new(AtomicU32::new(0)), + needs_unlock: Arc::new(AtomicBool::new(true)), + is_running: Arc::new(AtomicBool::new(true)), }; let stream = named_pipe_listener_stream::NamedPipeServerStream::new( agent_state.cancellation_token.clone(), @@ -31,7 +34,9 @@ impl BitwardenDesktopAgent { let cloned_agent_state = agent_state.clone(); tokio::spawn(async move { - *cloned_agent_state.is_running.lock().await = true; + cloned_agent_state + .is_running + .store(true, std::sync::atomic::Ordering::Relaxed); let _ = ssh_agent::serve( stream, cloned_agent_state.clone(), @@ -39,7 +44,9 @@ impl BitwardenDesktopAgent { cloned_agent_state.cancellation_token.clone(), ) .await; - *cloned_agent_state.is_running.lock().await = false; + cloned_agent_state + .is_running + .store(false, std::sync::atomic::Ordering::Relaxed); }); Ok(agent_state) } diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 0eaba197919..b884829e77d 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -67,7 +67,7 @@ export declare namespace sshagent { status: SshKeyImportStatus sshKey?: SshKey } - export function serve(callback: (err: Error | null, arg0: string, arg1: boolean) => any): Promise + export function serve(callback: (err: Error | null, arg0: string | undefined | null, arg1: boolean, arg2: string) => any): Promise export function stop(agentState: SshAgentState): void export function isRunning(agentState: SshAgentState): boolean export function setKeys(agentState: SshAgentState, newKeys: Array): void diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 5037108afd7..a7e2144b1dc 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -247,30 +247,28 @@ pub mod sshagent { #[napi] pub async fn serve( - callback: ThreadsafeFunction<(String, bool), CalleeHandled>, + callback: ThreadsafeFunction<(Option, bool, String), CalleeHandled>, ) -> napi::Result { let (auth_request_tx, mut auth_request_rx) = - tokio::sync::mpsc::channel::<(u32, (String, bool))>(32); + tokio::sync::mpsc::channel::(32); let (auth_response_tx, auth_response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(32); let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx)); tokio::spawn(async move { let _ = auth_response_rx; - while let Some((request_id, (cipher_uuid, is_list_request))) = - auth_request_rx.recv().await - { - let cloned_request_id = request_id.clone(); - let cloned_cipher_uuid = cipher_uuid.clone(); + while let Some(request) = auth_request_rx.recv().await { let cloned_response_tx_arc = auth_response_tx_arc.clone(); let cloned_callback = callback.clone(); tokio::spawn(async move { - let request_id = cloned_request_id; - let cipher_uuid = cloned_cipher_uuid; let auth_response_tx_arc = cloned_response_tx_arc; let callback = cloned_callback; let promise_result: Result, napi::Error> = callback - .call_async(Ok((cipher_uuid, is_list_request))) + .call_async(Ok(( + request.cipher_id, + request.is_list, + request.process_name, + ))) .await; match promise_result { Ok(promise_result) => match promise_result.await { @@ -278,7 +276,7 @@ pub mod sshagent { let _ = auth_response_tx_arc .lock() .await - .send((request_id, result)) + .send((request.request_id, result)) .expect("should be able to send auth response to agent"); } Err(e) => { @@ -286,7 +284,7 @@ pub mod sshagent { let _ = auth_response_tx_arc .lock() .await - .send((request_id, false)) + .send((request.request_id, false)) .expect("should be able to send auth response to agent"); } }, @@ -295,7 +293,7 @@ pub mod sshagent { let _ = auth_response_tx_arc .lock() .await - .send((request_id, false)) + .send((request.request_id, false)) .expect("should be able to send auth response to agent"); } } diff --git a/apps/desktop/desktop_native/napi/src/registry/windows.rs b/apps/desktop/desktop_native/napi/src/registry/windows.rs index 481dfb5dc49..aeb381dafda 100644 --- a/apps/desktop/desktop_native/napi/src/registry/windows.rs +++ b/apps/desktop/desktop_native/napi/src/registry/windows.rs @@ -13,7 +13,7 @@ pub fn create_key(key: &str, subkey: &str, value: &str) -> Result<()> { let key = convert_key(key)?; let subkey = key.create(subkey)?; - + const DEFAULT: &str = ""; subkey.set_string(DEFAULT, value)?; diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index 9b894b0bfc7..38f11a97a8b 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -241,7 +241,16 @@ "autoStart": true, "base": "core22", "confinement": "strict", - "plugs": ["default", "network-bind", "password-manager-service"], + "plugs": [ + "default", + "network-bind", + "password-manager-service", + { + "polkit": { + "action-prefix": "com.bitwarden.Bitwarden" + } + } + ], "stagePackages": ["default"] }, "protocols": [ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index eab9a7d7119..f546563ed18 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,7 +35,7 @@ "clean:dist": "rimraf ./dist", "pack:dir": "npm run clean:dist && electron-builder --dir -p never", "pack:lin:flatpak": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --repo=build/.repo build/.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ./build/.repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop", - "pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never", + "pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && mksquashfs ./dist/tmp-snap/ $SNAP_FILE -noappend -comp lzo -no-fragments && rm -rf ./dist/tmp-snap/", "pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never", "pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never", "pack:mac:mas": "npm run clean:dist && electron-builder --mac mas --universal -p never", diff --git a/apps/desktop/resources/com.bitwarden.desktop.policy b/apps/desktop/resources/com.bitwarden.desktop.policy new file mode 100644 index 00000000000..e48bc6b8fbb --- /dev/null +++ b/apps/desktop/resources/com.bitwarden.desktop.policy @@ -0,0 +1,16 @@ + + + + + + Unlock Bitwarden + Authenticate to unlock Bitwarden + + no + no + auth_self + + + diff --git a/apps/desktop/src/key-management/biometrics/biometric.unix.main.ts b/apps/desktop/src/key-management/biometrics/biometric.unix.main.ts index 771f1ea3a1c..f2bcf62e03e 100644 --- a/apps/desktop/src/key-management/biometrics/biometric.unix.main.ts +++ b/apps/desktop/src/key-management/biometrics/biometric.unix.main.ts @@ -87,8 +87,8 @@ export default class BiometricUnixMain implements OsBiometricService { } async authenticateBiometric(): Promise { - const hwnd = this.windowMain.win.getNativeWindowHandle(); - return await biometrics.prompt(hwnd, this.i18nservice.t("polkitConsentMessage")); + const hwnd = Buffer.from(""); + return await biometrics.prompt(hwnd, ""); } async osSupportsBiometric(): Promise { @@ -98,10 +98,14 @@ export default class BiometricUnixMain implements OsBiometricService { // This could be dynamically detected on dbus in the future. // We should check if a libsecret implementation is available on the system // because otherwise we cannot offlod the protected userkey to secure storage. - return (await passwords.isAvailable()) && !isSnapStore(); + return await passwords.isAvailable(); } async osBiometricsNeedsSetup(): Promise { + if (isSnapStore()) { + return false; + } + // check whether the polkit policy is loaded via dbus call to polkit return !(await biometrics.available()); } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index e4c235dada9..f8f81a5ac2c 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1734,9 +1734,6 @@ "windowsHelloConsentMessage": { "message": "Verify for Bitwarden." }, - "polkitConsentMessage": { - "message": "Authenticate to unlock Bitwarden." - }, "unlockWithTouchId": { "message": "Unlock with Touch ID" }, diff --git a/apps/desktop/src/platform/main/main-ssh-agent.service.ts b/apps/desktop/src/platform/main/main-ssh-agent.service.ts index 9141e30d820..8858134a6be 100644 --- a/apps/desktop/src/platform/main/main-ssh-agent.service.ts +++ b/apps/desktop/src/platform/main/main-ssh-agent.service.ts @@ -29,7 +29,7 @@ export class MainSshAgentService { init() { // handle sign request passing to UI sshagent - .serve(async (err: Error, cipherId: string, isListRequest: boolean) => { + .serve(async (err: Error, cipherId: string, isListRequest: boolean, processName: string) => { // clear all old (> SIGN_TIMEOUT) requests this.requestResponses = this.requestResponses.filter( (response) => response.timestamp > new Date(Date.now() - this.SIGN_TIMEOUT), @@ -41,6 +41,7 @@ export class MainSshAgentService { cipherId, isListRequest, requestId: id_for_this_request, + processName, }); const result = await firstValueFrom( diff --git a/apps/desktop/src/platform/services/ssh-agent.service.ts b/apps/desktop/src/platform/services/ssh-agent.service.ts index 9dc7abeca01..651e67e9467 100644 --- a/apps/desktop/src/platform/services/ssh-agent.service.ts +++ b/apps/desktop/src/platform/services/ssh-agent.service.ts @@ -122,6 +122,10 @@ export class SshAgentService implements OnDestroy { const cipherId = message.cipherId as string; const isListRequest = message.isListRequest as boolean; const requestId = message.requestId as number; + let application = message.processName as string; + if (application == "") { + application = this.i18nService.t("unknownApplication"); + } if (isListRequest) { const sshCiphers = ciphers.filter( @@ -151,7 +155,7 @@ export class SshAgentService implements OnDestroy { const dialogRef = ApproveSshRequestComponent.open( this.dialogService, cipher.name, - this.i18nService.t("unknownApplication"), + application, ); const result = await firstValueFrom(dialogRef.closed); diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index fa4d027d0f6..8387c53e5e3 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -98,7 +98,7 @@ *ngIf="canAccessExport$ | async" > diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index 91c965658a3..6ead83b01d8 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -23,6 +23,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { ProductTierType } from "@bitwarden/common/billing/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getById } from "@bitwarden/common/platform/misc"; import { BannerModule, IconModule } from "@bitwarden/components"; @@ -49,6 +50,7 @@ export class OrganizationLayoutComponent implements OnInit { protected readonly logo = AdminConsoleLogo; protected orgFilter = (org: Organization) => canAccessOrgAdmin(org); + protected domainVerificationNavigationTextKey: string; protected integrationPageEnabled$: Observable; @@ -67,6 +69,7 @@ export class OrganizationLayoutComponent implements OnInit { private configService: ConfigService, private policyService: PolicyService, private providerService: ProviderService, + private i18nService: I18nService, ) {} async ngOnInit() { @@ -116,6 +119,12 @@ export class OrganizationLayoutComponent implements OnInit { org.productTierType === ProductTierType.Enterprise && featureFlagEnabled, ), ); + + this.domainVerificationNavigationTextKey = (await this.configService.getFeatureFlag( + FeatureFlag.AccountDeprovisioning, + )) + ? "claimedDomains" + : "domainVerification"; } canShowVaultTab(organization: Organization): boolean { diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index 878672a1fb9..93751f0ef72 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -8,7 +8,7 @@
{{ "selectAPlan" | i18n }} -
+
{ this.policies = policies; @@ -451,10 +454,20 @@ export class EventService { msg = humanReadableMsg = this.i18nService.t("removedDomain", ev.domainName); break; case EventType.OrganizationDomain_Verified: - msg = humanReadableMsg = this.i18nService.t("domainVerifiedEvent", ev.domainName); + msg = humanReadableMsg = this.i18nService.t( + (await this.configService.getFeatureFlag(FeatureFlag.AccountDeprovisioning)) + ? "domainClaimedEvent" + : "domainVerifiedEvent", + ev.domainName, + ); break; case EventType.OrganizationDomain_NotVerified: - msg = humanReadableMsg = this.i18nService.t("domainNotVerifiedEvent", ev.domainName); + msg = humanReadableMsg = this.i18nService.t( + (await this.configService.getFeatureFlag(FeatureFlag.AccountDeprovisioning)) + ? "domainNotClaimedEvent" + : "domainNotVerifiedEvent", + ev.domainName, + ); break; // Secrets Manager case EventType.Secret_Retrieved: diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 06728929912..b1203230688 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9801,8 +9801,8 @@ "selfHostingTitleProper": { "message": "Self-Hosting" }, - "verified-domain-single-org-warning" : { - "message": "Verifying a domain will turn on the single organization policy." + "claim-domain-single-org-warning" : { + "message": "Claiming a domain will turn on the single organization policy." }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." @@ -9902,5 +9902,62 @@ }, "removeMembers": { "message": "Remove members" + }, + "claimedDomains": { + "message": "Claimed domains" + }, + "claimDomain": { + "message": "Claim domain" + }, + "reclaimDomain": { + "message": "Reclaim domain" + }, + "claimDomainNameInputHint": { + "message": "Example: mydomain.com. Subdomains require separate entries to be claimed." + }, + "automaticClaimedDomains": { + "message": "Automatic Claimed Domains" + }, + "automaticDomainClaimProcess": { + "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." + }, + "domainNotClaimed": { + "message": "$DOMAIN$ not claimed. Check your DNS records.", + "placeholders": { + "DOMAIN": { + "content": "$1", + "example": "bitwarden.com" + } + } + }, + "domainStatusClaimed": { + "message": "Claimed" + }, + "domainStatusUnderVerification": { + "message": "Under verification" + }, + "claimedDomainsDesc": { + "message": "Claim a domain to own all member accounts whose email address matches the domain. Members will be able to skip the SSO identifier when logging in. Administrators will also be able to delete member accounts." + }, + "invalidDomainNameClaimMessage": { + "message": "Input is not a valid format. Format: mydomain.com. Subdomains require separate entries to be claimed." + }, + "domainClaimedEvent": { + "message": "$DOMAIN$ claimed", + "placeholders": { + "DOMAIN": { + "content": "$1", + "example": "bitwarden.com" + } + } + }, + "domainNotClaimedEvent": { + "message": "$DOMAIN$ not claimed", + "placeholders": { + "DOMAIN": { + "content": "$1", + "example": "bitwarden.com" + } + } } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html index 15120eed92a..7226c957598 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html @@ -6,24 +6,37 @@ {{ "newDomain" | i18n }} - {{ "verifyDomain" | i18n }} + + {{ + ((accountDeprovisioningEnabled$ | async) ? "claimDomain" : "verifyDomain") | i18n + }} {{ data.orgDomain.domainName }} {{ - "domainStatusUnverified" | i18n + ((accountDeprovisioningEnabled$ | async) + ? "domainStatusUnderVerification" + : "domainStatusUnverified" + ) | i18n }} {{ - "domainStatusVerified" | i18n + ((accountDeprovisioningEnabled$ | async) ? "domainStatusClaimed" : "domainStatusVerified") + | i18n }}
{{ "domainName" | i18n }} - {{ "domainNameInputHint" | i18n }} + {{ + ((accountDeprovisioningEnabled$ | async) + ? "claimDomainNameInputHint" + : "domainNameInputHint" + ) | i18n + }} @@ -42,18 +55,29 @@ - {{ "automaticDomainVerificationProcess" | i18n }} + {{ + ((accountDeprovisioningEnabled$ | async) + ? "automaticDomainClaimProcess" + : "automaticDomainVerificationProcess" + ) | i18n + }}
+

+ {{ "claimedDomainsDesc" | i18n }} + + + +

+ {{ - "domainStatusUnverified" | i18n + ((accountDeprovisioningEnabled$ | async) + ? "domainStatusUnderVerification" + : "domainStatusUnverified" + ) | i18n }} {{ - "domainStatusVerified" | i18n + ((accountDeprovisioningEnabled$ | async) + ? "domainStatusClaimed" + : "domainStatusVerified" + ) | i18n }} @@ -70,7 +90,10 @@ type="button" > - {{ "verifyDomain" | i18n }} + {{ + ((accountDeprovisioningEnabled$ | async) ? "claimDomain" : "verifyDomain") + | i18n + }} + + + + + {{ "loading" | i18n }} + + + +

{{ "noClientsInList" | i18n }}

+ + + + {{ "name" | i18n }} + {{ "numberOfUsers" | i18n }} + {{ "billingPlan" | i18n }} + + + + + + + + {{ row.organizationName }} + + + {{ row.userCount }} + / {{ row.seats }} + + + {{ row.plan }} + + + + + + + +
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/vnext-clients.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/vnext-clients.component.ts new file mode 100644 index 00000000000..ba56ce872b2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/vnext-clients.component.ts @@ -0,0 +1,167 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl } from "@angular/forms"; +import { ActivatedRoute, Router, RouterModule } from "@angular/router"; +import { firstValueFrom, from, map } from "rxjs"; +import { debounceTime, first, switchMap } from "rxjs/operators"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; +import { PlanType } from "@bitwarden/common/billing/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { + AvatarModule, + DialogService, + TableDataSource, + TableModule, + ToastService, +} from "@bitwarden/components"; +import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/shared"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; + +import { WebProviderService } from "../services/web-provider.service"; + +import { AddOrganizationComponent } from "./add-organization.component"; + +const DisallowedPlanTypes = [ + PlanType.Free, + PlanType.FamiliesAnnually2019, + PlanType.FamiliesAnnually, + PlanType.TeamsStarter2023, + PlanType.TeamsStarter, +]; + +@Component({ + templateUrl: "vnext-clients.component.html", + standalone: true, + imports: [ + SharedOrganizationModule, + HeaderModule, + CommonModule, + JslibModule, + AvatarModule, + RouterModule, + TableModule, + ], +}) +export class vNextClientsComponent { + providerId: string; + addableOrganizations: Organization[]; + loading = true; + manageOrganizations = false; + showAddExisting = false; + dataSource: TableDataSource = + new TableDataSource(); + protected searchControl = new FormControl("", { nonNullable: true }); + + constructor( + private router: Router, + private providerService: ProviderService, + private apiService: ApiService, + private organizationService: OrganizationService, + private organizationApiService: OrganizationApiServiceAbstraction, + private activatedRoute: ActivatedRoute, + private dialogService: DialogService, + private i18nService: I18nService, + private toastService: ToastService, + private validationService: ValidationService, + private webProviderService: WebProviderService, + ) { + this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => { + this.searchControl.setValue(queryParams.search); + }); + + this.activatedRoute.parent.params + .pipe( + switchMap((params) => { + this.providerId = params.providerId; + return this.providerService.get$(this.providerId).pipe( + map((provider) => provider?.providerStatus === ProviderStatusType.Billable), + map((isBillable) => { + if (isBillable) { + return from( + this.router.navigate(["../manage-client-organizations"], { + relativeTo: this.activatedRoute, + }), + ); + } else { + return from(this.load()); + } + }), + ); + }), + takeUntilDestroyed(), + ) + .subscribe(); + + this.searchControl.valueChanges + .pipe(debounceTime(200), takeUntilDestroyed()) + .subscribe((searchText) => { + this.dataSource.filter = (data) => + data.organizationName.toLowerCase().indexOf(searchText.toLowerCase()) > -1; + }); + } + + async remove(organization: ProviderOrganizationOrganizationDetailsResponse) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: organization.organizationName, + content: { key: "detachOrganizationConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + try { + await this.webProviderService.detachOrganization(this.providerId, organization.id); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("detachedOrganization", organization.organizationName), + }); + await this.load(); + } catch (e) { + this.validationService.showError(e); + } + } + + async load() { + const response = await this.apiService.getProviderClients(this.providerId); + const clients = response.data != null && response.data.length > 0 ? response.data : []; + this.dataSource.data = clients; + this.manageOrganizations = + (await this.providerService.get(this.providerId)).type === ProviderUserType.ProviderAdmin; + const candidateOrgs = (await this.organizationService.getAll()).filter( + (o) => o.isOwner && o.providerId == null, + ); + const allowedOrgsIds = await Promise.all( + candidateOrgs.map((o) => this.organizationApiService.get(o.id)), + ).then((orgs) => + orgs.filter((o) => !DisallowedPlanTypes.includes(o.planType)).map((o) => o.id), + ); + this.addableOrganizations = candidateOrgs.filter((o) => allowedOrgsIds.includes(o.id)); + + this.showAddExisting = this.addableOrganizations.length !== 0; + this.loading = false; + } + + async addExistingOrganization() { + const dialogRef = AddOrganizationComponent.open(this.dialogService, { + providerId: this.providerId, + organizations: this.addableOrganizations, + }); + + if (await firstValueFrom(dialogRef.closed)) { + await this.load(); + } + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts index 00c944e69bb..09276263332 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts @@ -2,8 +2,10 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { authGuard } from "@bitwarden/angular/auth/guards"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { AnonLayoutWrapperComponent } from "@bitwarden/auth/angular"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component"; import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component"; @@ -12,10 +14,12 @@ import { ProviderSubscriptionComponent, hasConsolidatedBilling, ProviderBillingHistoryComponent, + vNextManageClientsComponent, } from "../../billing/providers"; import { ClientsComponent } from "./clients/clients.component"; import { CreateOrganizationComponent } from "./clients/create-organization.component"; +import { vNextClientsComponent } from "./clients/vnext-clients.component"; import { providerPermissionsGuard } from "./guards/provider-permissions.guard"; import { AcceptProviderComponent } from "./manage/accept-provider.component"; import { EventsComponent } from "./manage/events.component"; @@ -82,13 +86,25 @@ const routes: Routes = [ children: [ { path: "", pathMatch: "full", redirectTo: "clients" }, { path: "clients/create", component: CreateOrganizationComponent }, - { path: "clients", component: ClientsComponent, data: { titleId: "clients" } }, - { - path: "manage-client-organizations", - canActivate: [hasConsolidatedBilling], - component: ManageClientsComponent, - data: { titleId: "clients" }, - }, + ...featureFlaggedRoute({ + defaultComponent: ClientsComponent, + flaggedComponent: vNextClientsComponent, + featureFlag: FeatureFlag.PM12443RemovePagingLogic, + routeOptions: { + path: "clients", + data: { titleId: "clients" }, + }, + }), + ...featureFlaggedRoute({ + defaultComponent: ManageClientsComponent, + flaggedComponent: vNextManageClientsComponent, + featureFlag: FeatureFlag.PM12443RemovePagingLogic, + routeOptions: { + path: "manage-client-organizations", + data: { titleId: "clients" }, + canActivate: [hasConsolidatedBilling], + }, + }), { path: "manage", children: [ diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html index fa9665ff457..0731820e413 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html @@ -31,7 +31,10 @@ {{ "ssoIdentifierHintPartOne" | i18n }} - {{ "domainVerification" | i18n }} + {{ + ((accountDeprovisioningEnabled$ | async) ? "claimedDomains" : "domainVerification") + | i18n + }} diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts index 87f2a3dd9d9..6449ef7a701 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts @@ -9,7 +9,7 @@ import { Validators, } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { concatMap, Subject, takeUntil } from "rxjs"; +import { concatMap, Observable, Subject, takeUntil } from "rxjs"; import { ControlsOf } from "@bitwarden/angular/types/controls-of"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -28,6 +28,7 @@ import { SsoConfigApi } from "@bitwarden/common/auth/models/api/sso-config.api"; import { OrganizationSsoRequest } from "@bitwarden/common/auth/models/request/organization-sso.request"; import { OrganizationSsoResponse } from "@bitwarden/common/auth/models/response/organization-sso.response"; import { SsoConfigView } from "@bitwarden/common/auth/models/view/sso-config.view"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -185,6 +186,8 @@ export class SsoComponent implements OnInit, OnDestroy { return this.ssoConfigForm?.controls?.configType as FormControl; } + accountDeprovisioningEnabled$: Observable; + constructor( private formBuilder: FormBuilder, private route: ActivatedRoute, @@ -195,7 +198,11 @@ export class SsoComponent implements OnInit, OnDestroy { private organizationApiService: OrganizationApiServiceAbstraction, private configService: ConfigService, private toastService: ToastService, - ) {} + ) { + this.accountDeprovisioningEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.AccountDeprovisioning, + ); + } async ngOnInit() { this.enabledCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((enabled) => { diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts index ae7bf487f99..f8b344372ef 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts @@ -3,3 +3,4 @@ export * from "./manage-clients.component"; export * from "./manage-client-name-dialog.component"; export * from "./manage-client-subscription-dialog.component"; export * from "./no-clients.component"; +export * from "./vnext-manage-clients.component"; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-manage-clients.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-manage-clients.component.html new file mode 100644 index 00000000000..c54965bbdb6 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-manage-clients.component.html @@ -0,0 +1,83 @@ + + + + + {{ "addNewOrganization" | i18n }} + + + + + + {{ "loading" | i18n }} + + + + + + {{ "client" | i18n }} + {{ "assigned" | i18n }} + {{ "used" | i18n }} + {{ "remaining" | i18n }} + {{ "billingPlan" | i18n }} + + + + + + + + + + + {{ row.seats }} + + + {{ row.occupiedSeats }} + + + {{ row.remainingSeats }} + + + {{ row.plan }} + + + + + + + + + + + +
+ +
+
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-manage-clients.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-manage-clients.component.ts new file mode 100644 index 00000000000..5ee7817f34e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-manage-clients.component.ts @@ -0,0 +1,201 @@ +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl } from "@angular/forms"; +import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom, from, lastValueFrom, map } from "rxjs"; +import { debounceTime, first, switchMap } from "rxjs/operators"; + +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { + AvatarModule, + DialogService, + TableDataSource, + TableModule, + ToastService, +} from "@bitwarden/components"; +import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/shared"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; + +import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; + +import { + CreateClientDialogResultType, + openCreateClientDialog, +} from "./create-client-dialog.component"; +import { + ManageClientNameDialogResultType, + openManageClientNameDialog, +} from "./manage-client-name-dialog.component"; +import { + ManageClientSubscriptionDialogResultType, + openManageClientSubscriptionDialog, +} from "./manage-client-subscription-dialog.component"; +import { vNextNoClientsComponent } from "./vnext-no-clients.component"; + +@Component({ + templateUrl: "vnext-manage-clients.component.html", + standalone: true, + imports: [ + AvatarModule, + TableModule, + HeaderModule, + SharedOrganizationModule, + vNextNoClientsComponent, + ], +}) +export class vNextManageClientsComponent { + providerId: string; + provider: Provider; + loading = true; + isProviderAdmin = false; + dataSource: TableDataSource = + new TableDataSource(); + + protected searchControl = new FormControl("", { nonNullable: true }); + protected plans: PlanResponse[]; + + constructor( + private billingApiService: BillingApiServiceAbstraction, + private providerService: ProviderService, + private router: Router, + private activatedRoute: ActivatedRoute, + private dialogService: DialogService, + private i18nService: I18nService, + private toastService: ToastService, + private validationService: ValidationService, + private webProviderService: WebProviderService, + ) { + this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => { + this.searchControl.setValue(queryParams.search); + }); + + this.activatedRoute.parent.params + .pipe( + switchMap((params) => { + this.providerId = params.providerId; + return this.providerService.get$(this.providerId).pipe( + map((provider: Provider) => provider?.providerStatus === ProviderStatusType.Billable), + map((isBillable) => { + if (!isBillable) { + return from( + this.router.navigate(["../clients"], { + relativeTo: this.activatedRoute, + }), + ); + } else { + return from(this.load()); + } + }), + ); + }), + takeUntilDestroyed(), + ) + .subscribe(); + + this.searchControl.valueChanges + .pipe(debounceTime(200), takeUntilDestroyed()) + .subscribe((searchText) => { + this.dataSource.filter = (data) => + data.organizationName.toLowerCase().indexOf(searchText.toLowerCase()) > -1; + }); + } + + async load() { + this.provider = await firstValueFrom(this.providerService.get$(this.providerId)); + + this.isProviderAdmin = this.provider.type === ProviderUserType.ProviderAdmin; + + const clients = (await this.billingApiService.getProviderClientOrganizations(this.providerId)) + .data; + + clients.forEach((client) => (client.plan = client.plan.replace(" (Monthly)", ""))); + + this.dataSource.data = clients; + + this.plans = (await this.billingApiService.getPlans()).data; + + this.loading = false; + } + + createClient = async () => { + const reference = openCreateClientDialog(this.dialogService, { + data: { + providerId: this.providerId, + plans: this.plans, + }, + }); + + const result = await lastValueFrom(reference.closed); + + if (result === CreateClientDialogResultType.Submitted) { + await this.load(); + } + }; + + manageClientName = async (organization: ProviderOrganizationOrganizationDetailsResponse) => { + const dialogRef = openManageClientNameDialog(this.dialogService, { + data: { + providerId: this.providerId, + organization: { + id: organization.id, + name: organization.organizationName, + seats: organization.seats, + }, + }, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if (result === ManageClientNameDialogResultType.Submitted) { + await this.load(); + } + }; + + manageClientSubscription = async ( + organization: ProviderOrganizationOrganizationDetailsResponse, + ) => { + const dialogRef = openManageClientSubscriptionDialog(this.dialogService, { + data: { + organization, + provider: this.provider, + }, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if (result === ManageClientSubscriptionDialogResultType.Submitted) { + await this.load(); + } + }; + + async remove(organization: ProviderOrganizationOrganizationDetailsResponse) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: organization.organizationName, + content: { key: "detachOrganizationConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + try { + await this.webProviderService.detachOrganization(this.providerId, organization.id); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("detachedOrganization", organization.organizationName), + }); + await this.load(); + } catch (e) { + this.validationService.showError(e); + } + } +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-no-clients.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-no-clients.component.ts new file mode 100644 index 00000000000..5ad19945c51 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-no-clients.component.ts @@ -0,0 +1,50 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; + +import { svgIcon } from "@bitwarden/components"; +import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/shared"; + +const gearIcon = svgIcon` + + + + + + + + + + + + + + + + +`; + +@Component({ + selector: "app-no-clients", + standalone: true, + imports: [SharedOrganizationModule], + template: `
+ +

{{ "noClients" | i18n }}

+ + + {{ "addNewOrganization" | i18n }} + +
`, +}) +export class vNextNoClientsComponent { + icon = gearIcon; + @Input() showAddOrganizationButton = true; + @Output() addNewOrganizationClicked = new EventEmitter(); + + addNewOrganization = () => this.addNewOrganizationClicked.emit(); +} diff --git a/libs/angular/src/directives/text-drag.directive.ts b/libs/angular/src/directives/text-drag.directive.ts index da3e70d1de2..443fbdac157 100644 --- a/libs/angular/src/directives/text-drag.directive.ts +++ b/libs/angular/src/directives/text-drag.directive.ts @@ -17,6 +17,6 @@ export class TextDragDirective { @HostListener("dragstart", ["$event"]) onDragStart(event: DragEvent) { - event.dataTransfer.setData("text", this.data); + event.dataTransfer?.setData("text", this.data); } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index f79ebf8aa55..6597c97b641 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -40,6 +40,7 @@ export enum FeatureFlag { DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship", MacOsNativeCredentialSync = "macos-native-credential-sync", PM11360RemoveProviderExportPermission = "pm-11360-remove-provider-export-permission", + PM12443RemovePagingLogic = "pm-12443-remove-paging-logic", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -90,6 +91,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.DisableFreeFamiliesSponsorship]: FALSE, [FeatureFlag.MacOsNativeCredentialSync]: FALSE, [FeatureFlag.PM11360RemoveProviderExportPermission]: FALSE, + [FeatureFlag.PM12443RemovePagingLogic]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;