diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 22c14f6b433..255ddc08c80 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -162,6 +162,7 @@ apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-autofill-desktop- libs/components @bitwarden/team-ui-foundation libs/assets @bitwarden/team-ui-foundation libs/ui @bitwarden/team-ui-foundation +libs/angular/src/scss @bitwarden/team-ui-foundation apps/browser/src/platform/popup/layout @bitwarden/team-ui-foundation apps/browser/src/popup/app-routing.animations.ts @bitwarden/team-ui-foundation apps/browser/src/popup/components/extension-anon-layout-wrapper @bitwarden/team-ui-foundation diff --git a/.gitignore b/.gitignore index 0f609335b63..61a20195592 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ Thumbs.db .settings/ *.sublime-workspace .claude +.serena # Visual Studio Code .vscode/* diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 72c3892af62..4e399a530e1 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -550,8 +550,13 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" + }, + "archiveVerb": { + "message": "Archive", + "description": "Verb" }, "unarchive": { "message": "Unarchive" diff --git a/apps/browser/src/auth/services/new-device-verification/extension-new-device-verification-component.service.spec.ts b/apps/browser/src/auth/services/new-device-verification/extension-new-device-verification-component.service.spec.ts new file mode 100644 index 00000000000..7c91cae3fcb --- /dev/null +++ b/apps/browser/src/auth/services/new-device-verification/extension-new-device-verification-component.service.spec.ts @@ -0,0 +1,21 @@ +import { ExtensionNewDeviceVerificationComponentService } from "./extension-new-device-verification-component.service"; + +describe("ExtensionNewDeviceVerificationComponentService", () => { + let sut: ExtensionNewDeviceVerificationComponentService; + + beforeEach(() => { + sut = new ExtensionNewDeviceVerificationComponentService(); + }); + + it("should instantiate the service", () => { + expect(sut).not.toBeFalsy(); + }); + + describe("showBackButton()", () => { + it("should return false", () => { + const result = sut.showBackButton(); + + expect(result).toBe(false); + }); + }); +}); diff --git a/apps/browser/src/auth/services/new-device-verification/extension-new-device-verification-component.service.ts b/apps/browser/src/auth/services/new-device-verification/extension-new-device-verification-component.service.ts new file mode 100644 index 00000000000..05e60fc8dad --- /dev/null +++ b/apps/browser/src/auth/services/new-device-verification/extension-new-device-verification-component.service.ts @@ -0,0 +1,13 @@ +import { + DefaultNewDeviceVerificationComponentService, + NewDeviceVerificationComponentService, +} from "@bitwarden/auth/angular"; + +export class ExtensionNewDeviceVerificationComponentService + extends DefaultNewDeviceVerificationComponentService + implements NewDeviceVerificationComponentService +{ + showBackButton() { + return false; + } +} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index ef4dd0be090..7a10dc2343f 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -29,6 +29,7 @@ import { TwoFactorAuthDuoComponentService, TwoFactorAuthWebAuthnComponentService, SsoComponentService, + NewDeviceVerificationComponentService, } from "@bitwarden/auth/angular"; import { LockService, @@ -36,6 +37,7 @@ import { SsoUrlService, LogoutService, } from "@bitwarden/auth/common"; +import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -710,6 +712,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultCipherArchiveService, deps: [CipherService, ApiService, BillingAccountProfileStateService, ConfigService], }), + safeProvider({ + provide: NewDeviceVerificationComponentService, + useClass: ExtensionNewDeviceVerificationComponentService, + deps: [], + }), ]; @NgModule({ diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index 16cc04ce612..7ee7e141ee5 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -40,7 +40,7 @@ @if (canArchive$ | async) { } diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html index 630c55d0038..3c1278b4d44 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html @@ -37,7 +37,7 @@ @if (userCanArchive() || showArchiveFilter()) { - {{ "archive" | i18n }} + {{ "archiveNoun" | i18n }} diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 55f474f2992..9020e08362e 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -342,6 +342,7 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" name = "autotype" version = "0.0.0" dependencies = [ + "anyhow", "tracing", "windows 0.61.1", "windows-core 0.61.0", @@ -2897,9 +2898,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b369d18893388b345804dc0007963c99b7d665ae71d275812d828c6f089640" +checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a" dependencies = [ "bitflags", "core-foundation", diff --git a/apps/desktop/desktop_native/autotype/Cargo.toml b/apps/desktop/desktop_native/autotype/Cargo.toml index ceccd0c890a..3d1e74254ce 100644 --- a/apps/desktop/desktop_native/autotype/Cargo.toml +++ b/apps/desktop/desktop_native/autotype/Cargo.toml @@ -5,6 +5,9 @@ license.workspace = true edition.workspace = true publish.workspace = true +[dependencies] +anyhow = { workspace = true } + [target.'cfg(windows)'.dependencies] tracing.workspace = true windows = { workspace = true, features = [ diff --git a/apps/desktop/desktop_native/autotype/src/lib.rs b/apps/desktop/desktop_native/autotype/src/lib.rs index f1aab2ba164..92996996434 100644 --- a/apps/desktop/desktop_native/autotype/src/lib.rs +++ b/apps/desktop/desktop_native/autotype/src/lib.rs @@ -1,3 +1,5 @@ +use anyhow::Result; + #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] #[cfg_attr(target_os = "windows", path = "windows.rs")] @@ -5,18 +7,26 @@ mod windowing; /// Gets the title bar string for the foreground window. /// -/// TODO: The error handling will be improved in a future PR: PM-23615 -#[allow(clippy::result_unit_err)] -pub fn get_foreground_window_title() -> std::result::Result { +/// # Errors +/// +/// This function returns an `anyhow::Error` if there is any +/// issue obtaining the window title. Detailed reasons will +/// vary based on platform implementation. +pub fn get_foreground_window_title() -> Result { windowing::get_foreground_window_title() } /// Attempts to type the input text wherever the user's cursor is. /// -/// `input` must be an array of utf-16 encoded characters to insert. +/// # Arguments /// -/// TODO: The error handling will be improved in a future PR: PM-23615 -#[allow(clippy::result_unit_err)] -pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> std::result::Result<(), ()> { +/// * `input` must be an array of utf-16 encoded characters to insert. +/// +/// # Errors +/// +/// This function returns an `anyhow::Error` if there is any +/// issue obtaining the window title. Detailed reasons will +/// vary based on platform implementation. +pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { windowing::type_input(input, keyboard_shortcut) } diff --git a/apps/desktop/desktop_native/autotype/src/linux.rs b/apps/desktop/desktop_native/autotype/src/linux.rs index 148b1aab6eb..9fda0ed9e33 100644 --- a/apps/desktop/desktop_native/autotype/src/linux.rs +++ b/apps/desktop/desktop_native/autotype/src/linux.rs @@ -1,10 +1,7 @@ -pub fn get_foreground_window_title() -> std::result::Result { +pub fn get_foreground_window_title() -> anyhow::Result { todo!("Bitwarden does not yet support Linux autotype"); } -pub fn type_input( - _input: Vec, - _keyboard_shortcut: Vec, -) -> std::result::Result<(), ()> { +pub fn type_input(_input: Vec, _keyboard_shortcut: Vec) -> anyhow::Result<()> { todo!("Bitwarden does not yet support Linux autotype"); } diff --git a/apps/desktop/desktop_native/autotype/src/macos.rs b/apps/desktop/desktop_native/autotype/src/macos.rs index 5542e7a3a6b..c6681a3291e 100644 --- a/apps/desktop/desktop_native/autotype/src/macos.rs +++ b/apps/desktop/desktop_native/autotype/src/macos.rs @@ -1,10 +1,7 @@ -pub fn get_foreground_window_title() -> std::result::Result { +pub fn get_foreground_window_title() -> anyhow::Result { todo!("Bitwarden does not yet support macOS autotype"); } -pub fn type_input( - _input: Vec, - _keyboard_shortcut: Vec, -) -> std::result::Result<(), ()> { +pub fn type_input(_input: Vec, _keyboard_shortcut: Vec) -> anyhow::Result<()> { todo!("Bitwarden does not yet support macOS autotype"); } diff --git a/apps/desktop/desktop_native/autotype/src/windows.rs b/apps/desktop/desktop_native/autotype/src/windows.rs index 1d39d3f7ae5..1e125ef8e21 100644 --- a/apps/desktop/desktop_native/autotype/src/windows.rs +++ b/apps/desktop/desktop_native/autotype/src/windows.rs @@ -1,38 +1,141 @@ -use std::ffi::OsString; -use std::os::windows::ffi::OsStringExt; +use std::{ffi::OsString, os::windows::ffi::OsStringExt}; -use tracing::debug; -use windows::Win32::Foundation::{GetLastError, HWND}; -use windows::Win32::UI::Input::KeyboardAndMouse::{ - SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, - VIRTUAL_KEY, -}; -use windows::Win32::UI::WindowsAndMessaging::{ - GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW, +use anyhow::{anyhow, Result}; +use tracing::{debug, error, warn}; +use windows::Win32::{ + Foundation::{GetLastError, SetLastError, HWND, WIN32_ERROR}, + UI::{ + Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, + KEYEVENTF_UNICODE, VIRTUAL_KEY, + }, + WindowsAndMessaging::{GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW}, + }, }; +const WIN32_SUCCESS: WIN32_ERROR = WIN32_ERROR(0); + +fn clear_last_error() { + debug!("Clearing last error with SetLastError."); + unsafe { + SetLastError(WIN32_ERROR(0)); + } +} + +fn get_last_error() -> WIN32_ERROR { + let last_err = unsafe { GetLastError() }; + debug!("GetLastError(): {}", last_err.to_hresult().message()); + last_err +} + +// The handle should be validated before any unsafe calls referencing it. +fn validate_window_handle(handle: &HWND) -> Result<()> { + if handle.is_invalid() { + error!("Window handle is invalid."); + return Err(anyhow!("Window handle is invalid.")); + } + Ok(()) +} + +// ---------- Window title -------------- + /// Gets the title bar string for the foreground window. -pub fn get_foreground_window_title() -> std::result::Result { - let Ok(window_handle) = get_foreground_window() else { - return Err(()); - }; - let Ok(Some(window_title)) = get_window_title(window_handle) else { - return Err(()); - }; +pub fn get_foreground_window_title() -> Result { + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow + let window_handle = unsafe { GetForegroundWindow() }; - Ok(window_title) + debug!("GetForegroundWindow() called."); + + validate_window_handle(&window_handle)?; + + get_window_title(&window_handle) } +/// Gets the length of the window title bar text. +/// +/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw +fn get_window_title_length(window_handle: &HWND) -> Result { + // GetWindowTextLengthW does not itself clear the last error so we must do it ourselves. + clear_last_error(); + + validate_window_handle(window_handle)?; + + let length = unsafe { GetWindowTextLengthW(*window_handle) }; + + let length = usize::try_from(length)?; + + debug!(length, "window text length retrieved from handle."); + + if length == 0 { + // attempt to retreive win32 error + let last_err = get_last_error(); + if last_err != WIN32_SUCCESS { + let last_err = last_err.to_hresult().message(); + error!(last_err, "Error getting window text length."); + return Err(anyhow!("Error getting window text length: {last_err}")); + } + } + + Ok(length) +} + +/// Gets the window title bar title. +/// +/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw +fn get_window_title(window_handle: &HWND) -> Result { + let expected_window_title_length = get_window_title_length(window_handle)?; + + // This isn't considered an error by the windows API, but in practice it means we can't + // match against the title so we'll stop here. + // The upstream will make a contains comparison on what we return, so an empty string + // will not result on a match. + if expected_window_title_length == 0 { + warn!("Window title length is zero."); + return Ok(String::from("")); + } + + let mut buffer: Vec = vec![0; expected_window_title_length + 1]; // add extra space for the null character + + validate_window_handle(window_handle)?; + + let actual_window_title_length = unsafe { GetWindowTextW(*window_handle, &mut buffer) }; + + debug!(actual_window_title_length, "window title retrieved."); + + if actual_window_title_length == 0 { + // attempt to retreive win32 error + let last_err = get_last_error(); + if last_err != WIN32_SUCCESS { + let last_err = last_err.to_hresult().message(); + error!(last_err, "Error retrieving window title."); + return Err(anyhow!("Error retrieving window title. {last_err}")); + } + // in practice, we should not get to the below code, since we asserted the len > 0 + // above. but it is an extra protection in case the windows API didn't set an error. + warn!(expected_window_title_length, "No window title retrieved."); + } + + let window_title = OsString::from_wide(&buffer); + + Ok(window_title.to_string_lossy().into_owned()) +} + +// ---------- Type Input -------------- + /// Attempts to type the input text wherever the user's cursor is. /// /// `input` must be a vector of utf-16 encoded characters to insert. /// `keyboard_shortcut` must be a vector of Strings, where valid shortcut keys: Control, Alt, Super, Shift, letters a - Z /// /// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput -pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<(), ()> { +pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { const TAB_KEY: u8 = 9; - let mut keyboard_inputs: Vec = Vec::new(); + // the length of this vec is always shortcut keys to release + (2x length of input chars) + let mut keyboard_inputs: Vec = + Vec::with_capacity(keyboard_shortcut.len() + (input.len() * 2)); + + debug!(?keyboard_shortcut, "Converting keyboard shortcut to input."); // Add key "up" inputs for the shortcut for key in keyboard_shortcut { @@ -63,7 +166,7 @@ pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<(), /// Converts a valid shortcut key to an "up" keyboard input. /// /// `input` must be a valid shortcut key: Control, Alt, Super, Shift, letters [a-z][A-Z] -fn convert_shortcut_key_to_up_input(key: String) -> Result { +fn convert_shortcut_key_to_up_input(key: String) -> Result { const SHIFT_KEY: u8 = 0x10; const SHIFT_KEY_STR: &str = "Shift"; const CONTROL_KEY: u8 = 0x11; @@ -89,9 +192,15 @@ fn convert_shortcut_key_to_up_input(key: String) -> Result { /// Because we only accept [a-z][A-Z], the decimal u16 /// cast of the letter is safe because the unicode code point /// of these characters fits in a u16. -fn get_alphabetic_hotkey(letter: String) -> Result { +fn get_alphabetic_hotkey(letter: String) -> Result { if letter.len() != 1 { - return Err(()); + error!( + len = letter.len(), + "Final keyboard shortcut key should be a single character." + ); + return Err(anyhow!( + "Final keyboard shortcut key should be a single character: {letter}" + )); } let c = letter.chars().next().expect("letter is size 1"); @@ -99,65 +208,20 @@ fn get_alphabetic_hotkey(letter: String) -> Result { // is_ascii_alphabetic() checks for: // U+0041 `A` ..= U+005A `Z`, or U+0061 `a` ..= U+007A `z` if !c.is_ascii_alphabetic() { - return Err(()); + error!(letter = %c, "Letter is not ASCII Alphabetic ([a-z][A-Z])."); + return Err(anyhow!( + "Letter is not ASCII Alphabetic ([a-z][A-Z]): '{letter}'", + )); } - Ok(c as u16) + let c = c as u16; + + debug!(c, letter, "Got alphabetic hotkey."); + + Ok(c) } -/// Gets the foreground window handle. -/// -/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow -fn get_foreground_window() -> Result { - let foreground_window_handle = unsafe { GetForegroundWindow() }; - - if foreground_window_handle.is_invalid() { - return Err(()); - } - - Ok(foreground_window_handle) -} - -/// Gets the length of the window title bar text. -/// -/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw -fn get_window_title_length(window_handle: HWND) -> Result { - if window_handle.is_invalid() { - return Err(()); - } - - match usize::try_from(unsafe { GetWindowTextLengthW(window_handle) }) { - Ok(length) => Ok(length), - Err(_) => Err(()), - } -} - -/// Gets the window title bar title. -/// -/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw -fn get_window_title(window_handle: HWND) -> Result, ()> { - if window_handle.is_invalid() { - return Err(()); - } - - let window_title_length = get_window_title_length(window_handle)?; - if window_title_length == 0 { - return Ok(None); - } - - let mut buffer: Vec = vec![0; window_title_length + 1]; // add extra space for the null character - - let window_title_length = unsafe { GetWindowTextW(window_handle, &mut buffer) }; - if window_title_length == 0 { - return Ok(None); - } - - let window_title = OsString::from_wide(&buffer); - - Ok(Some(window_title.to_string_lossy().into_owned())) -} - -/// Used in build_input() to specify if an input key is being pressed (down) or released (up). +/// An input key can be either pressed (down), or released (up). enum InputKeyPress { Down, Up, @@ -233,18 +297,29 @@ fn build_virtual_key_input(key_press: InputKeyPress, virtual_key: u8) -> INPUT { /// Attempts to type the provided input wherever the user's cursor is. /// /// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput -fn send_input(inputs: Vec) -> Result<(), ()> { +fn send_input(inputs: Vec) -> Result<()> { let insert_count = unsafe { SendInput(&inputs, std::mem::size_of::() as i32) }; - let e = unsafe { GetLastError().to_hresult().message() }; - debug!("type_input() called, GetLastError() is: {:?}", e); + debug!("SendInput() called."); if insert_count == 0 { - return Err(()); // input was blocked by another thread + let last_err = get_last_error().to_hresult().message(); + error!(GetLastError = %last_err, "SendInput sent 0 inputs. Input was blocked by another thread."); + + return Err(anyhow!("SendInput sent 0 inputs. Input was blocked by another thread. GetLastError: {last_err}")); } else if insert_count != inputs.len() as u32 { - return Err(()); // input insertion not completed + let last_err = get_last_error().to_hresult().message(); + error!(sent = %insert_count, expected = inputs.len(), GetLastError = %last_err, + "SendInput sent does not match expected." + ); + return Err(anyhow!( + "SendInput does not match expected. sent: {insert_count}, expected: {}", + inputs.len() + )); } + debug!(insert_count, "Autotype sent input."); + Ok(()) } @@ -263,16 +338,16 @@ mod tests { } #[test] - #[should_panic = ""] + #[should_panic = "Final keyboard shortcut key should be a single character: foo"] fn get_alphabetic_hot_key_fail_not_single_char() { let letter = String::from("foo"); get_alphabetic_hotkey(letter).unwrap(); } #[test] - #[should_panic = ""] + #[should_panic = "Letter is not ASCII Alphabetic ([a-z][A-Z]): '}'"] fn get_alphabetic_hot_key_fail_not_alphabetic() { - let letter = String::from("🚀"); + let letter = String::from("}"); get_alphabetic_hotkey(letter).unwrap(); } } diff --git a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts index 7fb30333e28..555e6ceef5b 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts @@ -1,8 +1,10 @@ import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, take, timeout, TimeoutError } from "rxjs"; +import { BehaviorSubject, firstValueFrom, take } from "rxjs"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -18,10 +20,10 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { let policyService: MockProxy; let configService: MockProxy; - let mockAccountSubject: BehaviorSubject<{ id: UserId } | null>; + let mockAccountSubject: BehaviorSubject; let mockFeatureFlagSubject: BehaviorSubject; let mockAuthStatusSubject: BehaviorSubject; - let mockPolicyAppliesSubject: BehaviorSubject; + let mockPoliciesSubject: BehaviorSubject; const mockUserId = "user-123" as UserId; @@ -36,7 +38,7 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { mockAuthStatusSubject = new BehaviorSubject( AuthenticationStatus.Unlocked, ); - mockPolicyAppliesSubject = new BehaviorSubject(false); + mockPoliciesSubject = new BehaviorSubject([]); accountService = mock(); authService = mock(); @@ -50,9 +52,7 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { authService.authStatusFor$ = jest .fn() .mockImplementation((_: UserId) => mockAuthStatusSubject.asObservable()); - policyService.policyAppliesToUser$ = jest - .fn() - .mockReturnValue(mockPolicyAppliesSubject.asObservable()); + policyService.policies$ = jest.fn().mockReturnValue(mockPoliciesSubject.asObservable()); TestBed.configureTestingModule({ providers: [ @@ -72,7 +72,7 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { mockAccountSubject.complete(); mockFeatureFlagSubject.complete(); mockAuthStatusSubject.complete(); - mockPolicyAppliesSubject.complete(); + mockPoliciesSubject.complete(); }); describe("autotypeDefaultSetting$", () => { @@ -82,11 +82,20 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { expect(result).toBeNull(); }); - it("should not emit when no active account", async () => { + it("does not emit until an account appears", async () => { mockAccountSubject.next(null); - await expect( - firstValueFrom(service.autotypeDefaultSetting$.pipe(timeout({ first: 30 }))), - ).rejects.toBeInstanceOf(TimeoutError); + + mockAccountSubject.next({ id: mockUserId } as Account); + mockAuthStatusSubject.next(AuthenticationStatus.Unlocked); + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]); + + const result = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(result).toBe(true); }); it("should emit null when user is not unlocked", async () => { @@ -96,34 +105,56 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { }); it("should emit null when no autotype policy exists", async () => { - mockPolicyAppliesSubject.next(false); + mockPoliciesSubject.next([]); const policy = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(policy).toBeNull(); }); it("should emit true when autotype policy is enabled", async () => { - mockPolicyAppliesSubject.next(true); + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]); const policyStatus = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(policyStatus).toBe(true); }); - it("should emit false when autotype policy is disabled", async () => { - mockPolicyAppliesSubject.next(false); + it("should emit null when autotype policy is disabled", async () => { + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: false, + } as Policy, + ]); const policyStatus = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(policyStatus).toBeNull(); }); it("should emit null when autotype policy does not apply", async () => { - mockPolicyAppliesSubject.next(false); + mockPoliciesSubject.next([ + { + type: PolicyType.RequireSso, + enabled: true, + } as Policy, + ]); const policy = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(policy).toBeNull(); }); it("should react to authentication status changes", async () => { + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]); + // Expect one emission when unlocked mockAuthStatusSubject.next(AuthenticationStatus.Unlocked); const first = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); - expect(first).toBeNull(); + expect(first).toBe(true); // Expect null emission when locked mockAuthStatusSubject.next(AuthenticationStatus.Locked); @@ -134,33 +165,131 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { it("should react to account changes", async () => { const newUserId = "user-456" as UserId; + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]); + // First value for original user const firstValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); - expect(firstValue).toBeNull(); + expect(firstValue).toBe(true); // Change account and expect a new emission mockAccountSubject.next({ id: newUserId, - }); + } as Account); const secondValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); - expect(secondValue).toBeNull(); + expect(secondValue).toBe(true); // Verify the auth lookup was switched to the new user expect(authService.authStatusFor$).toHaveBeenCalledWith(newUserId); + expect(policyService.policies$).toHaveBeenCalledWith(newUserId); }); it("should react to policy changes", async () => { - mockPolicyAppliesSubject.next(false); + mockPoliciesSubject.next([]); const nullValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(nullValue).toBeNull(); - mockPolicyAppliesSubject.next(true); + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]); const trueValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(trueValue).toBe(true); - mockPolicyAppliesSubject.next(false); + mockPoliciesSubject.next([]); const nullValueAgain = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(nullValueAgain).toBeNull(); }); + + it("emits null again if the feature flag turns off after emitting", async () => { + mockPoliciesSubject.next([ + { type: PolicyType.AutotypeDefaultSetting, enabled: true } as Policy, + ]); + expect(await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1)))).toBe(true); + + mockFeatureFlagSubject.next(false); + expect(await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1)))).toBeNull(); + }); + + it("replays the latest value to late subscribers", async () => { + mockPoliciesSubject.next([ + { type: PolicyType.AutotypeDefaultSetting, enabled: true } as Policy, + ]); + + await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + + const late = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(late).toBe(true); + }); + + it("does not re-emit when effective value is unchanged", async () => { + mockAccountSubject.next({ id: mockUserId } as Account); + mockAuthStatusSubject.next(AuthenticationStatus.Unlocked); + + const policies = [ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]; + + mockPoliciesSubject.next(policies); + const first = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(first).toBe(true); + + let emissionCount = 0; + const subscription = service.autotypeDefaultSetting$.subscribe(() => { + emissionCount++; + }); + + mockPoliciesSubject.next(policies); + + await new Promise((resolve) => setTimeout(resolve, 50)); + subscription.unsubscribe(); + + expect(emissionCount).toBe(1); + }); + + it("does not emit policy values while locked; emits after unlocking", async () => { + mockAuthStatusSubject.next(AuthenticationStatus.Locked); + mockPoliciesSubject.next([ + { type: PolicyType.AutotypeDefaultSetting, enabled: true } as Policy, + ]); + + expect(await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1)))).toBeNull(); + + mockAuthStatusSubject.next(AuthenticationStatus.Unlocked); + expect(await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1)))).toBe(true); + }); + + it("emits correctly if auth unlocks before policies arrive", async () => { + mockAccountSubject.next({ id: mockUserId } as Account); + mockAuthStatusSubject.next(AuthenticationStatus.Unlocked); + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]); + + const result = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(result).toBe(true); + }); + + it("wires dependencies with initial user id", async () => { + mockPoliciesSubject.next([ + { type: PolicyType.AutotypeDefaultSetting, enabled: true } as Policy, + ]); + await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + + expect(authService.authStatusFor$).toHaveBeenCalledWith(mockUserId); + expect(policyService.policies$).toHaveBeenCalledWith(mockUserId); + }); }); }); diff --git a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts index 887a30ef6f6..d3ae67d2c8d 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts @@ -34,7 +34,7 @@ export class DesktopAutotypeDefaultSettingPolicy { } return this.accountService.activeAccount$.pipe( - filter((account) => account != null), + filter((account) => account != null && account.id != null), getUserId, distinctUntilChanged(), switchMap((userId) => { @@ -43,13 +43,16 @@ export class DesktopAutotypeDefaultSettingPolicy { distinctUntilChanged(), ); - const policy$ = this.policyService - .policyAppliesToUser$(PolicyType.AutotypeDefaultSetting, userId) - .pipe( - map((appliesToUser) => (appliesToUser ? true : null)), - distinctUntilChanged(), - shareReplay({ bufferSize: 1, refCount: true }), - ); + const policy$ = this.policyService.policies$(userId).pipe( + map((policies) => { + const autotypePolicy = policies.find( + (policy) => policy.type === PolicyType.AutotypeDefaultSetting && policy.enabled, + ); + return autotypePolicy ? true : null; + }), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); return isUnlocked$.pipe(switchMap((unlocked) => (unlocked ? policy$ : of(null)))); }), diff --git a/apps/desktop/src/autofill/services/desktop-autotype.service.ts b/apps/desktop/src/autofill/services/desktop-autotype.service.ts index 34f70be64cb..24ec3907a62 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype.service.ts @@ -1,6 +1,6 @@ import { combineLatest, filter, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; @@ -73,7 +73,9 @@ export class DesktopAutotypeService { async init() { this.autotypeEnabledUserSetting$ = this.autotypeEnabledState.state$; - this.autotypeKeyboardShortcut$ = this.autotypeKeyboardShortcut.state$; + this.autotypeKeyboardShortcut$ = this.autotypeKeyboardShortcut.state$.pipe( + map((shortcut) => shortcut ?? defaultWindowsAutotypeKeyboardShortcut), + ); // Currently Autotype is only supported for Windows if (this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop) { @@ -109,9 +111,9 @@ export class DesktopAutotypeService { switchMap((userId) => this.authService.authStatusFor$(userId)), ), this.accountService.activeAccount$.pipe( - map((activeAccount) => activeAccount?.id), - switchMap((userId) => - this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId), + filter((account): account is Account => !!account), + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), ), ), ]).pipe( diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 08ec76af874..a9b5efda357 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4114,8 +4114,13 @@ "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" + }, + "archiveVerb": { + "message": "Archive", + "description": "Verb" }, "unarchive": { "message": "Unarchive" diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 51315b9a1a5..3aab02b3b49 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -44,9 +44,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { EventType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -239,7 +237,6 @@ export class VaultComponent implements OnInit, OnDestroy { private totpService: TotpService, private apiService: ApiService, private toastService: ToastService, - private configService: ConfigService, private cipherFormConfigService: CipherFormConfigService, protected billingApiService: BillingApiServiceAbstraction, private accountService: AccountService, @@ -710,14 +707,13 @@ export class VaultComponent implements OnInit, OnDestroy { } async navigateToPaymentMethod() { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; const organizationId = await firstValueFrom(this.organizationId$); - await this.router.navigate(["organizations", `${organizationId}`, "billing", route], { - state: { launchPaymentModalAutomatically: true }, - }); + await this.router.navigate( + ["organizations", `${organizationId}`, "billing", "payment-details"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); } addAccessToggle(e: AddAccessStatusType) { 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 10290d52f1e..e5af0faa164 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 @@ -71,10 +71,9 @@ > - @let paymentDetailsPageData = paymentDetailsPageData$ | 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 a9b61debf89..b9d44c125ad 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,9 +23,6 @@ import { PolicyType, ProviderStatusType } from "@bitwarden/common/admin-console/ import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getById } from "@bitwarden/common/platform/misc"; import { BannerModule, IconModule } from "@bitwarden/components"; @@ -70,11 +67,6 @@ export class OrganizationLayoutComponent implements OnInit { protected showSponsoredFamiliesDropdown$: Observable; - protected paymentDetailsPageData$: Observable<{ - route: string; - textKey: string; - }>; - protected subscriber$: Observable; protected getTaxIdWarning$: () => Observable; @@ -82,12 +74,10 @@ export class OrganizationLayoutComponent implements OnInit { private route: ActivatedRoute, private organizationService: OrganizationService, private platformUtilsService: PlatformUtilsService, - private configService: ConfigService, private policyService: PolicyService, private providerService: ProviderService, private accountService: AccountService, private freeFamiliesPolicyService: FreeFamiliesPolicyService, - private organizationBillingService: OrganizationBillingServiceAbstraction, private organizationWarningsService: OrganizationWarningsService, ) {} @@ -141,16 +131,6 @@ export class OrganizationLayoutComponent implements OnInit { this.integrationPageEnabled$ = this.organization$.pipe(map((org) => org.canAccessIntegrations)); - this.paymentDetailsPageData$ = this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => - managePaymentDetailsOutsideCheckout - ? { route: "billing/payment-details", textKey: "paymentDetails" } - : { route: "billing/payment-method", textKey: "paymentMethod" }, - ), - ); - this.subscriber$ = this.organization$.pipe( map((organization) => ({ type: "organization", diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 5a1ae6cd98b..b31f1cbf358 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -975,12 +975,11 @@ export class MembersComponent extends BaseMembersComponent } async navigateToPaymentMethod(organization: Organization) { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + await this.router.navigate( + ["organizations", `${organization.id}`, "billing", "payment-details"], + { + state: { launchPaymentModalAutomatically: true }, + }, ); - const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; - await this.router.navigate(["organizations", `${organization.id}`, "billing", route], { - state: { launchPaymentModalAutomatically: true }, - }); } } diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html deleted file mode 100644 index 0c1a4270662..00000000000 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html +++ /dev/null @@ -1,104 +0,0 @@ - - - {{ "loading" | i18n }} - -
-
-
-

{{ "billingPlanLabel" | i18n }}

-
- -
-
- -
-
-
-

{{ "paymentType" | i18n }}

- - - - @if (trialLength === 0) { - @let priceLabel = - subscriptionProduct === SubscriptionProduct.PasswordManager - ? "passwordManagerPlanPrice" - : "secretsManagerPlanPrice"; - -
-
- {{ priceLabel | i18n }}: {{ getPriceFor(formGroup.value.cadence) | currency: "USD $" }} -
- {{ "estimatedTax" | i18n }}: - @if (fetchingTaxAmount) { - - } @else { - {{ taxAmount | currency: "USD $" }} - } -
-
-
-

- {{ "total" | i18n }}: - @if (fetchingTaxAmount) { - - } @else { - {{ total | currency: "USD $" }}/{{ interval | i18n }} - } -

-
- } -
-
- - -
-
-
- - - - {{ "loading" | i18n }} - diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts deleted file mode 100644 index 431f8882505..00000000000 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts +++ /dev/null @@ -1,360 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { - Component, - EventEmitter, - Input, - OnDestroy, - OnInit, - Output, - ViewChild, -} from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { firstValueFrom, from, Subject, switchMap, takeUntil } from "rxjs"; - -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { - BillingInformation, - OrganizationBillingServiceAbstraction as OrganizationBillingService, - OrganizationInformation, - PaymentInformation, - PlanInformation, -} from "@bitwarden/common/billing/abstractions/organization-billing.service"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { - PaymentMethodType, - PlanType, - ProductTierType, - ProductType, -} from "@bitwarden/common/billing/enums"; -import { PreviewTaxAmountForOrganizationTrialRequest } from "@bitwarden/common/billing/models/request/tax"; -import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { ToastService } from "@bitwarden/components"; - -import { BillingSharedModule } from "../../shared"; -import { PaymentComponent } from "../../shared/payment/payment.component"; - -export type TrialOrganizationType = Exclude; - -export interface OrganizationInfo { - name: string; - email: string; - type: TrialOrganizationType | null; -} - -export interface OrganizationCreatedEvent { - organizationId: string; - planDescription: string; -} - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -enum SubscriptionCadence { - Annual, - Monthly, -} - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum SubscriptionProduct { - PasswordManager, - SecretsManager, -} - -@Component({ - selector: "app-trial-billing-step", - templateUrl: "trial-billing-step.component.html", - imports: [BillingSharedModule], -}) -export class TrialBillingStepComponent implements OnInit, OnDestroy { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(ManageTaxInformationComponent) taxInfoComponent: ManageTaxInformationComponent; - @Input() organizationInfo: OrganizationInfo; - @Input() subscriptionProduct: SubscriptionProduct = SubscriptionProduct.PasswordManager; - @Input() trialLength: number; - @Output() steppedBack = new EventEmitter(); - @Output() organizationCreated = new EventEmitter(); - - loading = true; - fetchingTaxAmount = false; - - annualCadence = SubscriptionCadence.Annual; - monthlyCadence = SubscriptionCadence.Monthly; - - formGroup = this.formBuilder.group({ - cadence: [SubscriptionCadence.Annual, Validators.required], - }); - formPromise: Promise; - - applicablePlans: PlanResponse[]; - annualPlan?: PlanResponse; - monthlyPlan?: PlanResponse; - - taxAmount = 0; - - private destroy$ = new Subject(); - - protected readonly SubscriptionProduct = SubscriptionProduct; - - constructor( - private apiService: ApiService, - private i18nService: I18nService, - private formBuilder: FormBuilder, - private messagingService: MessagingService, - private organizationBillingService: OrganizationBillingService, - private toastService: ToastService, - private taxService: TaxServiceAbstraction, - private accountService: AccountService, - ) {} - - async ngOnInit(): Promise { - const plans = await this.apiService.getPlans(); - this.applicablePlans = plans.data.filter(this.isApplicable); - this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual); - this.monthlyPlan = this.findPlanFor(SubscriptionCadence.Monthly); - - if (this.trialLength === 0) { - this.formGroup.controls.cadence.valueChanges - .pipe( - switchMap((cadence) => from(this.previewTaxAmount(cadence))), - takeUntil(this.destroy$), - ) - .subscribe((taxAmount) => { - this.taxAmount = taxAmount; - }); - } - - this.loading = false; - } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - async submit(): Promise { - if (!this.taxInfoComponent.validate()) { - return; - } - - this.formPromise = this.createOrganization(); - - const organizationId = await this.formPromise; - const planDescription = this.getPlanDescription(); - - this.toastService.showToast({ - variant: "success", - title: this.i18nService.t("organizationCreated"), - message: this.i18nService.t("organizationReadyToGo"), - }); - - this.organizationCreated.emit({ - organizationId, - planDescription, - }); - - // TODO: No one actually listening to this? - this.messagingService.send("organizationCreated", { organizationId }); - } - - async onTaxInformationChanged() { - if (this.trialLength === 0) { - this.taxAmount = await this.previewTaxAmount(this.formGroup.value.cadence); - } - - this.paymentComponent.showBankAccount = - this.taxInfoComponent.getTaxInformation().country === "US"; - if ( - !this.paymentComponent.showBankAccount && - this.paymentComponent.selected === PaymentMethodType.BankAccount - ) { - this.paymentComponent.select(PaymentMethodType.Card); - } - } - - protected getPriceFor(cadence: SubscriptionCadence): number { - const plan = this.findPlanFor(cadence); - return this.subscriptionProduct === SubscriptionProduct.PasswordManager - ? plan.PasswordManager.basePrice === 0 - ? plan.PasswordManager.seatPrice - : plan.PasswordManager.basePrice - : plan.SecretsManager.basePrice === 0 - ? plan.SecretsManager.seatPrice - : plan.SecretsManager.basePrice; - } - - protected stepBack() { - this.steppedBack.emit(); - } - - private async createOrganization(): Promise { - const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - const planResponse = this.findPlanFor(this.formGroup.value.cadence); - - const { type, token } = await this.paymentComponent.tokenize(); - const paymentMethod: [string, PaymentMethodType] = [token, type]; - - const organization: OrganizationInformation = { - name: this.organizationInfo.name, - billingEmail: this.organizationInfo.email, - initiationPath: - this.subscriptionProduct === SubscriptionProduct.PasswordManager - ? "Password Manager trial from marketing website" - : "Secrets Manager trial from marketing website", - }; - - const plan: PlanInformation = { - type: planResponse.type, - passwordManagerSeats: 1, - }; - - if (this.subscriptionProduct === SubscriptionProduct.SecretsManager) { - plan.subscribeToSecretsManager = true; - plan.isFromSecretsManagerTrial = true; - plan.secretsManagerSeats = 1; - } - - const payment: PaymentInformation = { - paymentMethod, - billing: this.getBillingInformationFromTaxInfoComponent(), - skipTrial: this.trialLength === 0, - }; - - const response = await this.organizationBillingService.purchaseSubscription( - { - organization, - plan, - payment, - }, - activeUserId, - ); - - return response.id; - } - - private productTypeToPlanTypeMap: { - [productType in TrialOrganizationType]: { - [cadence in SubscriptionCadence]?: PlanType; - }; - } = { - [ProductTierType.Enterprise]: { - [SubscriptionCadence.Annual]: PlanType.EnterpriseAnnually, - [SubscriptionCadence.Monthly]: PlanType.EnterpriseMonthly, - }, - [ProductTierType.Families]: { - [SubscriptionCadence.Annual]: PlanType.FamiliesAnnually, - // No monthly option for Families plan - }, - [ProductTierType.Teams]: { - [SubscriptionCadence.Annual]: PlanType.TeamsAnnually, - [SubscriptionCadence.Monthly]: PlanType.TeamsMonthly, - }, - [ProductTierType.TeamsStarter]: { - // No annual option for Teams Starter plan - [SubscriptionCadence.Monthly]: PlanType.TeamsStarter, - }, - }; - - private findPlanFor(cadence: SubscriptionCadence): PlanResponse | null { - const productType = this.organizationInfo.type; - const planType = this.productTypeToPlanTypeMap[productType]?.[cadence]; - return planType ? this.applicablePlans.find((plan) => plan.type === planType) : null; - } - - protected get showTaxIdField(): boolean { - switch (this.organizationInfo.type) { - case ProductTierType.Families: - return false; - default: - return true; - } - } - - private getBillingInformationFromTaxInfoComponent(): BillingInformation { - return { - postalCode: this.taxInfoComponent.getTaxInformation()?.postalCode, - country: this.taxInfoComponent.getTaxInformation()?.country, - taxId: this.taxInfoComponent.getTaxInformation()?.taxId, - addressLine1: this.taxInfoComponent.getTaxInformation()?.line1, - addressLine2: this.taxInfoComponent.getTaxInformation()?.line2, - city: this.taxInfoComponent.getTaxInformation()?.city, - state: this.taxInfoComponent.getTaxInformation()?.state, - }; - } - - private getPlanDescription(): string { - const plan = this.findPlanFor(this.formGroup.value.cadence); - const price = - this.subscriptionProduct === SubscriptionProduct.PasswordManager - ? plan.PasswordManager.basePrice === 0 - ? plan.PasswordManager.seatPrice - : plan.PasswordManager.basePrice - : plan.SecretsManager.basePrice === 0 - ? plan.SecretsManager.seatPrice - : plan.SecretsManager.basePrice; - - switch (this.formGroup.value.cadence) { - case SubscriptionCadence.Annual: - return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`; - case SubscriptionCadence.Monthly: - return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`; - } - } - - private isApplicable(plan: PlanResponse): boolean { - const hasCorrectProductType = - plan.productTier === ProductTierType.Enterprise || - plan.productTier === ProductTierType.Families || - plan.productTier === ProductTierType.Teams || - plan.productTier === ProductTierType.TeamsStarter; - const notDisabledOrLegacy = !plan.disabled && !plan.legacyYear; - return hasCorrectProductType && notDisabledOrLegacy; - } - - private previewTaxAmount = async (cadence: SubscriptionCadence): Promise => { - this.fetchingTaxAmount = true; - - if (!this.taxInfoComponent.validate()) { - this.fetchingTaxAmount = false; - return 0; - } - - const plan = this.findPlanFor(cadence); - - const productType = - this.subscriptionProduct === SubscriptionProduct.PasswordManager - ? ProductType.PasswordManager - : ProductType.SecretsManager; - - const taxInformation = this.taxInfoComponent.getTaxInformation(); - - const request: PreviewTaxAmountForOrganizationTrialRequest = { - planType: plan.type, - productType, - taxInformation: { - ...taxInformation, - }, - }; - - const response = await this.taxService.previewTaxAmountForOrganizationTrial(request); - this.fetchingTaxAmount = false; - return response; - }; - - get price() { - return this.getPriceFor(this.formGroup.value.cadence); - } - - get total() { - return this.price + this.taxAmount; - } - - get interval() { - return this.formGroup.value.cadence === SubscriptionCadence.Annual ? "year" : "month"; - } -} diff --git a/apps/web/src/app/billing/clients/index.ts b/apps/web/src/app/billing/clients/index.ts index 3ba2dde03a1..0251693a3b2 100644 --- a/apps/web/src/app/billing/clients/index.ts +++ b/apps/web/src/app/billing/clients/index.ts @@ -1,3 +1,4 @@ export * from "./organization-billing.client"; export * from "./subscriber-billing.client"; +export * from "./tax.client"; export * from "./account-billing.client"; diff --git a/apps/web/src/app/billing/clients/subscriber-billing.client.ts b/apps/web/src/app/billing/clients/subscriber-billing.client.ts index 18ca215ef0c..107a8ccc728 100644 --- a/apps/web/src/app/billing/clients/subscriber-billing.client.ts +++ b/apps/web/src/app/billing/clients/subscriber-billing.client.ts @@ -82,6 +82,24 @@ export class SubscriberBillingClient { return data ? new MaskedPaymentMethodResponse(data).value : null; }; + restartSubscription = async ( + subscriber: BitwardenSubscriber, + paymentMethod: TokenizedPaymentMethod, + billingAddress: BillingAddress, + ): Promise => { + const path = `${this.getEndpoint(subscriber)}/subscription/restart`; + await this.apiService.send( + "POST", + path, + { + paymentMethod, + billingAddress, + }, + true, + false, + ); + }; + updateBillingAddress = async ( subscriber: BitwardenSubscriber, billingAddress: BillingAddress, diff --git a/apps/web/src/app/billing/clients/tax.client.ts b/apps/web/src/app/billing/clients/tax.client.ts new file mode 100644 index 00000000000..09debd5a210 --- /dev/null +++ b/apps/web/src/app/billing/clients/tax.client.ts @@ -0,0 +1,131 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types"; + +class TaxAmountResponse extends BaseResponse implements TaxAmounts { + tax: number; + total: number; + + constructor(response: any) { + super(response); + + this.tax = this.getResponseProperty("Tax"); + this.total = this.getResponseProperty("Total"); + } +} + +export type OrganizationSubscriptionPlan = { + tier: "families" | "teams" | "enterprise"; + cadence: "annually" | "monthly"; +}; + +export type OrganizationSubscriptionPurchase = OrganizationSubscriptionPlan & { + passwordManager: { + seats: number; + additionalStorage: number; + sponsored: boolean; + }; + secretsManager?: { + seats: number; + additionalServiceAccounts: number; + standalone: boolean; + }; +}; + +export type OrganizationSubscriptionUpdate = { + passwordManager?: { + seats?: number; + additionalStorage?: number; + }; + secretsManager?: { + seats?: number; + additionalServiceAccounts?: number; + }; +}; + +export interface TaxAmounts { + tax: number; + total: number; +} + +@Injectable() +export class TaxClient { + constructor(private apiService: ApiService) {} + + previewTaxForOrganizationSubscriptionPurchase = async ( + purchase: OrganizationSubscriptionPurchase, + billingAddress: BillingAddress, + ): Promise => { + const json = await this.apiService.send( + "POST", + "/billing/tax/organizations/subscriptions/purchase", + { + purchase, + billingAddress, + }, + true, + true, + ); + + return new TaxAmountResponse(json); + }; + + previewTaxForOrganizationSubscriptionPlanChange = async ( + organizationId: string, + plan: { + tier: "families" | "teams" | "enterprise"; + cadence: "annually" | "monthly"; + }, + billingAddress: BillingAddress | null, + ): Promise => { + const json = await this.apiService.send( + "POST", + `/billing/tax/organizations/${organizationId}/subscription/plan-change`, + { + plan, + billingAddress, + }, + true, + true, + ); + + return new TaxAmountResponse(json); + }; + + previewTaxForOrganizationSubscriptionUpdate = async ( + organizationId: string, + update: OrganizationSubscriptionUpdate, + ): Promise => { + const json = await this.apiService.send( + "POST", + `/billing/tax/organizations/${organizationId}/subscription/update`, + { + update, + }, + true, + true, + ); + + return new TaxAmountResponse(json); + }; + + previewTaxForPremiumSubscriptionPurchase = async ( + additionalStorage: number, + billingAddress: BillingAddress, + ): Promise => { + const json = await this.apiService.send( + "POST", + `/billing/tax/premium/subscriptions/purchase`, + { + additionalStorage, + billingAddress, + }, + true, + true, + ); + + return new TaxAmountResponse(json); + }; +} diff --git a/apps/web/src/app/billing/index.ts b/apps/web/src/app/billing/index.ts index 217f1e05be9..a3047bbab6a 100644 --- a/apps/web/src/app/billing/index.ts +++ b/apps/web/src/app/billing/index.ts @@ -1,2 +1 @@ export { OrganizationPlansComponent } from "./organizations"; -export { TaxInfoComponent } from "./shared"; diff --git a/apps/web/src/app/billing/individual/index.ts b/apps/web/src/app/billing/individual/index.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index 87b342ed997..bb0ca60b677 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -3,8 +3,6 @@ import { RouterModule, Routes } from "@angular/router"; import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component"; -import { PaymentMethodComponent } from "../shared"; - import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { PremiumComponent } from "./premium/premium.component"; import { SubscriptionComponent } from "./subscription.component"; @@ -27,11 +25,6 @@ const routes: Routes = [ component: PremiumComponent, data: { titleId: "goPremium" }, }, - { - path: "payment-method", - component: PaymentMethodComponent, - data: { titleId: "paymentMethod" }, - }, { path: "payment-details", component: AccountPaymentDetailsComponent, diff --git a/apps/web/src/app/billing/individual/individual-billing.module.ts b/apps/web/src/app/billing/individual/individual-billing.module.ts index ad75da00c99..20f2a6cc143 100644 --- a/apps/web/src/app/billing/individual/individual-billing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing.module.ts @@ -1,5 +1,10 @@ import { NgModule } from "@angular/core"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, +} from "@bitwarden/web-vault/app/billing/payment/components"; + import { HeaderModule } from "../../layouts/header/header.module"; import { BillingSharedModule } from "../shared"; @@ -10,7 +15,13 @@ import { SubscriptionComponent } from "./subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component"; @NgModule({ - imports: [IndividualBillingRoutingModule, BillingSharedModule, HeaderModule], + imports: [ + IndividualBillingRoutingModule, + BillingSharedModule, + HeaderModule, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, + ], declarations: [ SubscriptionComponent, BillingHistoryViewComponent, diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts index 9f46d9d3909..ca7902542de 100644 --- a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts @@ -1,22 +1,7 @@ import { Component } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { - BehaviorSubject, - EMPTY, - filter, - from, - map, - merge, - Observable, - shareReplay, - switchMap, - tap, -} from "rxjs"; -import { catchError } from "rxjs/operators"; +import { BehaviorSubject, filter, merge, Observable, shareReplay, switchMap, tap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; @@ -28,13 +13,6 @@ import { import { MaskedPaymentMethod } from "../../payment/types"; import { mapAccountToSubscriber, BitwardenSubscriber } from "../../types"; -class RedirectError { - constructor( - public path: string[], - public relativeTo: ActivatedRoute, - ) {} -} - type View = { account: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; @@ -56,23 +34,11 @@ export class AccountPaymentDetailsComponent { private viewState$ = new BehaviorSubject(null); private load$: Observable = this.accountService.activeAccount$.pipe( - switchMap((account) => - this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => { - if (!managePaymentDetailsOutsideCheckout) { - throw new RedirectError(["../payment-method"], this.activatedRoute); - } - return account; - }), - ), - ), mapAccountToSubscriber, switchMap(async (account) => { const [paymentMethod, credit] = await Promise.all([ - this.billingClient.getPaymentMethod(account), - this.billingClient.getCredit(account), + this.subscriberBillingClient.getPaymentMethod(account), + this.subscriberBillingClient.getCredit(account), ]); return { @@ -82,14 +48,6 @@ export class AccountPaymentDetailsComponent { }; }), shareReplay({ bufferSize: 1, refCount: false }), - catchError((error: unknown) => { - if (error instanceof RedirectError) { - return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( - switchMap(() => EMPTY), - ); - } - throw error; - }), ); view$: Observable = merge( @@ -99,10 +57,7 @@ export class AccountPaymentDetailsComponent { constructor( private accountService: AccountService, - private activatedRoute: ActivatedRoute, - private billingClient: SubscriberBillingClient, - private configService: ConfigService, - private router: Router, + private subscriberBillingClient: SubscriberBillingClient, ) {} setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => { diff --git a/apps/web/src/app/billing/individual/premium/premium.component.html b/apps/web/src/app/billing/individual/premium/premium.component.html index 3f0f97541df..52ebe7803df 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.html +++ b/apps/web/src/app/billing/individual/premium/premium.component.html @@ -70,7 +70,7 @@ (onLicenseFileUploaded)="onLicenseFileSelectedChanged()" /> -
+

{{ "addons" | i18n }}

@@ -93,15 +93,25 @@

{{ "summary" | i18n }}

{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }}
- {{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB × + {{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB × {{ storageGBPrice | currency: "$" }} = {{ additionalStorageCost | currency: "$" }}

{{ "paymentInformation" | i18n }}

- - +
+ + + + +
{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }} diff --git a/apps/web/src/app/billing/individual/premium/premium.component.ts b/apps/web/src/app/billing/individual/premium/premium.component.ts index 974c22455ff..d5062e34881 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -9,36 +9,34 @@ import { debounceTime } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { PreviewIndividualInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-individual-invoice.request"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { ToastService } from "@bitwarden/components"; - -import { PaymentComponent } from "../../shared/payment/payment.component"; -import { TaxInfoComponent } from "../../shared/tax-info.component"; +import { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { tokenizablePaymentMethodToLegacyEnum } from "@bitwarden/web-vault/app/billing/payment/types"; @Component({ templateUrl: "./premium.component.html", standalone: false, + providers: [TaxClient], }) export class PremiumComponent { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected hasPremiumFromAnyOrganization$: Observable; - protected addOnFormGroup = new FormGroup({ + protected formGroup = new FormGroup({ additionalStorage: new FormControl(0, [Validators.min(0), Validators.max(99)]), - }); - - protected licenseFormGroup = new FormGroup({ - file: new FormControl(null, [Validators.required]), + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), }); protected cloudWebVaultURL: string; @@ -53,16 +51,14 @@ export class PremiumComponent { private activatedRoute: ActivatedRoute, private apiService: ApiService, private billingAccountProfileStateService: BillingAccountProfileStateService, - private configService: ConfigService, private environmentService: EnvironmentService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private router: Router, private syncService: SyncService, private toastService: ToastService, - private tokenService: TokenService, - private taxService: TaxServiceAbstraction, private accountService: AccountService, + private taxClient: TaxClient, ) { this.isSelfHost = this.platformUtilsService.isSelfHost(); @@ -93,11 +89,13 @@ export class PremiumComponent { ) .subscribe(); - this.addOnFormGroup.controls.additionalStorage.valueChanges - .pipe(debounceTime(1000), takeUntilDestroyed()) - .subscribe(() => { - this.refreshSalesTax(); - }); + this.formGroup.valueChanges + .pipe( + debounceTime(1000), + switchMap(async () => await this.refreshSalesTax()), + takeUntilDestroyed(), + ) + .subscribe(); } finalizeUpgrade = async () => { @@ -117,53 +115,21 @@ export class PremiumComponent { navigateToSubscriptionPage = (): Promise => this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); - onLicenseFileSelected = (event: Event): void => { - const element = event.target as HTMLInputElement; - this.licenseFormGroup.value.file = element.files.length > 0 ? element.files[0] : null; - }; - - submitPremiumLicense = async (): Promise => { - this.licenseFormGroup.markAllAsTouched(); - - if (this.licenseFormGroup.invalid) { - return this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("selectFile"), - }); - } - - const emailVerified = await this.tokenService.getEmailVerified(); - if (!emailVerified) { - return this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("verifyEmailFirst"), - }); - } - - const formData = new FormData(); - formData.append("license", this.licenseFormGroup.value.file); - - await this.apiService.postAccountLicense(formData); - await this.finalizeUpgrade(); - await this.postFinalizeUpgrade(); - }; - submitPayment = async (): Promise => { - this.taxInfoComponent.taxFormGroup.markAllAsTouched(); - if (this.taxInfoComponent.taxFormGroup.invalid) { + if (this.formGroup.invalid) { return; } - const { type, token } = await this.paymentComponent.tokenize(); + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + + const legacyEnum = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); const formData = new FormData(); - formData.append("paymentMethodType", type.toString()); - formData.append("paymentToken", token); - formData.append("additionalStorageGb", this.addOnFormGroup.value.additionalStorage.toString()); - formData.append("country", this.taxInfoComponent.country); - formData.append("postalCode", this.taxInfoComponent.postalCode); + formData.append("paymentMethodType", legacyEnum.toString()); + formData.append("paymentToken", paymentMethod.token); + formData.append("additionalStorageGb", this.formGroup.value.additionalStorage.toString()); + formData.append("country", this.formGroup.value.billingAddress.country); + formData.append("postalCode", this.formGroup.value.billingAddress.postalCode); await this.apiService.postPremium(formData); await this.finalizeUpgrade(); @@ -171,7 +137,7 @@ export class PremiumComponent { }; protected get additionalStorageCost(): number { - return this.storageGBPrice * this.addOnFormGroup.value.additionalStorage; + return this.storageGBPrice * this.formGroup.value.additionalStorage; } protected get premiumURL(): string { @@ -190,35 +156,18 @@ export class PremiumComponent { await this.postFinalizeUpgrade(); } - private refreshSalesTax(): void { - if (!this.taxInfoComponent.country || !this.taxInfoComponent.postalCode) { + private async refreshSalesTax(): Promise { + if (this.formGroup.invalid) { return; } - const request: PreviewIndividualInvoiceRequest = { - passwordManager: { - additionalStorage: this.addOnFormGroup.value.additionalStorage, - }, - taxInformation: { - postalCode: this.taxInfoComponent.postalCode, - country: this.taxInfoComponent.country, - }, - }; - this.taxService - .previewIndividualInvoice(request) - .then((invoice) => { - this.estimatedTax = invoice.taxAmount; - }) - .catch((error) => { - this.toastService.showToast({ - title: "", - variant: "error", - message: this.i18nService.t(error.message), - }); - }); - } + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); - protected onTaxInformationChanged(): void { - this.refreshSalesTax(); + const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase( + this.formGroup.value.additionalStorage, + billingAddress, + ); + + this.estimatedTax = taxAmounts.tax; } } diff --git a/apps/web/src/app/billing/individual/subscription.component.html b/apps/web/src/app/billing/individual/subscription.component.html index fa2eb0412a9..f9a46cf56ad 100644 --- a/apps/web/src/app/billing/individual/subscription.component.html +++ b/apps/web/src/app/billing/individual/subscription.component.html @@ -3,10 +3,7 @@ {{ "subscription" | i18n }} - @let paymentMethodPageData = paymentDetailsPageData$ | async; - {{ - paymentMethodPageData.textKey | i18n - }} + {{ "paymentDetails" | i18n }} {{ "billingHistory" | i18n }} diff --git a/apps/web/src/app/billing/individual/subscription.component.ts b/apps/web/src/app/billing/individual/subscription.component.ts index c6a20a9f6a3..2a08ec85127 100644 --- a/apps/web/src/app/billing/individual/subscription.component.ts +++ b/apps/web/src/app/billing/individual/subscription.component.ts @@ -1,12 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; -import { map, Observable, switchMap } from "rxjs"; +import { Observable, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @Component({ @@ -15,32 +13,16 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl }) export class SubscriptionComponent implements OnInit { hasPremium$: Observable; - paymentDetailsPageData$: Observable<{ - route: string; - textKey: string; - }>; - selfHosted: boolean; constructor( private platformUtilsService: PlatformUtilsService, billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, - private configService: ConfigService, ) { this.hasPremium$ = accountService.activeAccount$.pipe( switchMap((account) => billingAccountProfileStateService.hasPremiumPersonally$(account.id)), ); - - this.paymentDetailsPageData$ = this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => - managePaymentDetailsOutsideCheckout - ? { route: "payment-details", textKey: "paymentDetails" } - : { route: "payment-method", textKey: "paymentMethod" }, - ), - ); } ngOnInit() { 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 f899b8eccb4..abd7bdb155a 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 @@ -328,24 +328,60 @@ *ngIf="formGroup.value.productTier !== productTypes.Free || isSubscriptionCanceled" >

{{ "paymentMethod" | i18n }}

-

- - {{ paymentSource?.description }} - - {{ "changePaymentMethod" | i18n }} - +

+ @switch (paymentMethod.type) { + @case ("bankAccount") { + + {{ paymentMethod.bankName }}, *{{ paymentMethod.last4 }} + @if (paymentMethod.hostedVerificationUrl) { + - {{ "unverified" | i18n }} + } + + {{ "changePaymentMethod" | i18n }} + + } + @case ("card") { +

+ @let cardBrandIcon = getCardBrandIcon(); + @if (cardBrandIcon !== null) { + + } @else { + + } + {{ paymentMethod.brand | titlecase }}, *{{ paymentMethod.last4 }}, + {{ paymentMethod.expiration }} + + {{ "changePaymentMethod" | i18n }} + +

+ } + @case ("payPal") { + + {{ paymentMethod.email }} + + {{ "changePaymentMethod" | i18n }} + + } + }

- - + + + +

diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 6fc2dc57ba2..2b5c27e0f09 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -12,9 +12,9 @@ import { } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; -import { firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs"; +import { combineLatest, firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs"; +import { debounceTime } from "rxjs/operators"; -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { @@ -28,28 +28,8 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/ import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { - BillingApiServiceAbstraction, - BillingInformation, - OrganizationBillingServiceAbstraction as OrganizationBillingService, - OrganizationInformation, - PaymentInformation, - PlanInformation, -} from "@bitwarden/common/billing/abstractions"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { - PaymentMethodType, - PlanInterval, - PlanType, - ProductTierType, -} from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; -import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request"; -import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request"; -import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response"; +import { PlanInterval, PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; -import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -57,6 +37,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { OrganizationId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { + CardComponent, DIALOG_DATA, DialogConfig, DialogRef, @@ -64,11 +45,25 @@ import { ToastService, } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { UserId } from "@bitwarden/user-core"; +import { + OrganizationSubscriptionPlan, + SubscriberBillingClient, + TaxClient, +} from "@bitwarden/web-vault/app/billing/clients"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + BillingAddress, + getCardBrandIcon, + MaskedPaymentMethod, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; import { BillingNotificationService } from "../services/billing-notification.service"; import { BillingSharedModule } from "../shared/billing-shared.module"; -import { PaymentComponent } from "../shared/payment/payment.component"; type ChangePlanDialogParams = { organizationId: string; @@ -111,11 +106,16 @@ interface OnSuccessArgs { @Component({ templateUrl: "./change-plan-dialog.component.html", - imports: [BillingSharedModule], + imports: [ + BillingSharedModule, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, + CardComponent, + ], + providers: [SubscriberBillingClient, TaxClient], }) export class ChangePlanDialogComponent implements OnInit, OnDestroy { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent; + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent: EnterPaymentMethodComponent; @Input() acceptingSponsorship = false; @Input() organizationId: string; @@ -172,7 +172,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { clientOwnerEmail: ["", [Validators.email]], plan: [this.plan], productTier: [this.productTier], - // planInterval: [1], + }); + + billingFormGroup = this.formBuilder.group({ + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), }); planType: string; @@ -183,7 +187,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { secretsManagerPlans: PlanResponse[]; organization: Organization; sub: OrganizationSubscriptionResponse; - billing: BillingResponse; dialogHeaderName: string; currentPlanName: string; showPayment: boolean = false; @@ -191,15 +194,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { currentPlan: PlanResponse; isCardStateDisabled = false; focusedIndex: number | null = null; - accountCredit: number; - paymentSource?: PaymentSourceResponse; plans: ListResponse; isSubscriptionCanceled: boolean = false; secretsManagerTotal: number; - private destroy$ = new Subject(); + paymentMethod: MaskedPaymentMethod | null; + billingAddress: BillingAddress | null; - protected taxInformation: TaxInformation; + private destroy$ = new Subject(); constructor( @Inject(DIALOG_DATA) private dialogParams: ChangePlanDialogParams, @@ -215,11 +217,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { private messagingService: MessagingService, private formBuilder: FormBuilder, private organizationApiService: OrganizationApiServiceAbstraction, - private billingApiService: BillingApiServiceAbstraction, - private taxService: TaxServiceAbstraction, private accountService: AccountService, - private organizationBillingService: OrganizationBillingService, private billingNotificationService: BillingNotificationService, + private subscriberBillingClient: SubscriberBillingClient, + private taxClient: TaxClient, ) {} async ngOnInit(): Promise { @@ -242,10 +243,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ); if (this.sub?.subscription?.status !== "canceled") { try { - const { accountCredit, paymentSource } = - await this.billingApiService.getOrganizationPaymentMethod(this.organizationId); - this.accountCredit = accountCredit; - this.paymentSource = paymentSource; + const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization }; + const [paymentMethod, billingAddress] = await Promise.all([ + this.subscriberBillingClient.getPaymentMethod(subscriber), + this.subscriberBillingClient.getBillingAddress(subscriber), + ]); + + this.paymentMethod = paymentMethod; + this.billingAddress = billingAddress; } catch (error) { this.billingNotificationService.handleError(error); } @@ -307,15 +312,24 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ? 0 : (this.sub?.customerDiscount?.percentOff ?? 0); - this.setInitialPlanSelection(); - this.loading = false; - - const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId); - this.taxInformation = TaxInformation.from(taxInfo); - + await this.setInitialPlanSelection(); if (!this.isSubscriptionCanceled) { - this.refreshSalesTax(); + await this.refreshSalesTax(); } + + combineLatest([ + this.billingFormGroup.controls.billingAddress.controls.country.valueChanges, + this.billingFormGroup.controls.billingAddress.controls.postalCode.valueChanges, + this.billingFormGroup.controls.billingAddress.controls.taxId.valueChanges, + ]) + .pipe( + debounceTime(1000), + switchMap(async () => await this.refreshSalesTax()), + takeUntil(this.destroy$), + ) + .subscribe(); + + this.loading = false; } resolveHeaderName(subscription: OrganizationSubscriptionResponse): string { @@ -333,10 +347,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ); } - setInitialPlanSelection() { + async setInitialPlanSelection() { this.focusedIndex = this.selectableProducts.length - 1; if (!this.isSubscriptionCanceled) { - this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); + await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); } } @@ -344,10 +358,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return this.selectableProducts.find((product) => product.productTier === productTier); } - isPaymentSourceEmpty() { - return this.paymentSource === null || this.paymentSource === undefined; - } - isSecretsManagerTrial(): boolean { return ( this.sub?.subscription?.items?.some((item) => @@ -356,13 +366,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ); } - planTypeChanged() { - this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); + async planTypeChanged() { + await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); } - updateInterval(event: number) { + async updateInterval(event: number) { this.selectedInterval = event; - this.planTypeChanged(); + await this.planTypeChanged(); } protected getPlanIntervals() { @@ -460,7 +470,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } } - protected selectPlan(plan: PlanResponse) { + protected async selectPlan(plan: PlanResponse) { if ( this.selectedInterval === PlanInterval.Monthly && plan.productTier == ProductTierType.Families @@ -475,7 +485,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.formGroup.patchValue({ productTier: plan.productTier }); try { - this.refreshSalesTax(); + await this.refreshSalesTax(); } catch { this.estimatedTax = 0; } @@ -489,19 +499,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { get upgradeRequiresPaymentMethod() { const isFreeTier = this.organization?.productTierType === ProductTierType.Free; const shouldHideFree = !this.showFree; - const hasNoPaymentSource = !this.paymentSource; + const hasNoPaymentSource = !this.paymentMethod; return isFreeTier && shouldHideFree && hasNoPaymentSource; } - get selectedSecretsManagerPlan() { - let planResponse: PlanResponse; - if (this.secretsManagerPlans) { - return this.secretsManagerPlans.find((plan) => plan.type === this.selectedPlan.type); - } - return planResponse; - } - get selectedPlanInterval() { if (this.isSubscriptionCanceled) { return this.currentPlan.isAnnual ? "year" : "month"; @@ -591,8 +593,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return 0; } - const result = plan.PasswordManager.seatPrice * Math.abs(this.sub?.seats || 0); - return result; + return plan.PasswordManager.seatPrice * Math.abs(this.sub?.seats || 0); } secretsManagerSeatTotal(plan: PlanResponse, seats: number): number { @@ -746,39 +747,22 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.formGroup.controls.additionalSeats.setValue(1); } - changedCountry() { - this.paymentComponent.showBankAccount = this.taxInformation.country === "US"; - - if ( - !this.paymentComponent.showBankAccount && - this.paymentComponent.selected === PaymentMethodType.BankAccount - ) { - this.paymentComponent.select(PaymentMethodType.Card); - } - } - - protected taxInformationChanged(event: TaxInformation): void { - this.taxInformation = event; - this.changedCountry(); - this.refreshSalesTax(); - } - submit = async () => { - if (this.taxComponent !== undefined && !this.taxComponent.validate()) { - this.taxComponent.markAllAsTouched(); + this.formGroup.markAllAsTouched(); + this.billingFormGroup.markAllAsTouched(); + if (this.formGroup.invalid || (this.billingFormGroup.invalid && !this.paymentMethod)) { return; } const doSubmit = async (): Promise => { - const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - let orgId: string = null; + let orgId: string; const sub = this.sub?.subscription; const isCanceled = sub?.status === "canceled"; const isCancelledDowngradedToFreeOrg = sub?.cancelled && this.organization.productTierType === ProductTierType.Free; if (isCanceled || isCancelledDowngradedToFreeOrg) { - await this.restartSubscription(activeUserId); + await this.restartSubscription(); orgId = this.organizationId; } else { orgId = await this.updateOrganization(); @@ -795,9 +779,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { await this.syncService.fullSync(true); if (!this.acceptingSponsorship && !this.isInTrialFlow) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/organizations/" + orgId + "/billing/subscription"]); + await this.router.navigate(["/organizations/" + orgId + "/billing/subscription"]); } if (this.isInTrialFlow) { @@ -818,46 +800,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.dialogRef.close(); }; - private async restartSubscription(activeUserId: UserId) { - const org = await this.organizationApiService.get(this.organizationId); - const organization: OrganizationInformation = { - name: org.name, - billingEmail: org.billingEmail, - }; - - const filteredPlan = this.plans.data - .filter((plan) => plan.productTier === this.selectedPlan.productTier && !plan.legacyYear) - .find((plan) => { - const isSameBillingCycle = plan.isAnnual === this.selectedPlan.isAnnual; - return isSameBillingCycle; - }); - - const plan: PlanInformation = { - type: filteredPlan.type, - passwordManagerSeats: org.seats, - }; - - if (org.useSecretsManager) { - plan.subscribeToSecretsManager = true; - plan.secretsManagerSeats = org.smSeats; - } - - const { type, token } = await this.paymentComponent.tokenize(); - const paymentMethod: [string, PaymentMethodType] = [token, type]; - - const payment: PaymentInformation = { + private async restartSubscription() { + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + const billingAddress = getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress); + await this.subscriberBillingClient.restartSubscription( + { type: "organization", data: this.organization }, paymentMethod, - billing: this.getBillingInformationFromTaxInfoComponent(), - }; - - await this.organizationBillingService.restartSubscription( - this.organization.id, - { - organization, - plan, - payment, - }, - activeUserId, + billingAddress, ); } @@ -875,25 +824,25 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.formGroup.controls.premiumAccessAddon.value; request.planType = this.selectedPlan.type; if (this.showPayment) { - request.billingAddressCountry = this.taxInformation.country; - request.billingAddressPostalCode = this.taxInformation.postalCode; + request.billingAddressCountry = this.billingFormGroup.controls.billingAddress.value.country; + request.billingAddressPostalCode = + this.billingFormGroup.controls.billingAddress.value.postalCode; } // Secrets Manager this.buildSecretsManagerRequest(request); - if (this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty()) { - const tokenizedPaymentSource = await this.paymentComponent.tokenize(); - const updatePaymentMethodRequest = new UpdatePaymentMethodRequest(); - updatePaymentMethodRequest.paymentSource = tokenizedPaymentSource; - updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From( - this.taxInformation, + if (this.upgradeRequiresPaymentMethod || this.showPayment || !this.paymentMethod) { + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + const billingAddress = getBillingAddressFromForm( + this.billingFormGroup.controls.billingAddress, ); - await this.billingApiService.updateOrganizationPaymentMethod( - this.organizationId, - updatePaymentMethodRequest, - ); + const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization }; + await Promise.all([ + this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null), + this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress), + ]); } // Backfill pub/priv key if necessary @@ -931,18 +880,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return text; } - private getBillingInformationFromTaxInfoComponent(): BillingInformation { - return { - country: this.taxInformation.country, - postalCode: this.taxInformation.postalCode, - taxId: this.taxInformation.taxId, - addressLine1: this.taxInformation.line1, - addressLine2: this.taxInformation.line2, - city: this.taxInformation.city, - state: this.taxInformation.state, - }; - } - private buildSecretsManagerRequest(request: OrganizationUpgradeRequest): void { request.useSecretsManager = this.organization.useSecretsManager; if (!this.organization.useSecretsManager) { @@ -1002,25 +939,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } calculateTotalAppliedDiscount(total: number) { - const discountedTotal = total * (this.discountPercentageFromSub / 100); - return discountedTotal; - } - - get paymentSourceClasses() { - if (this.paymentSource == null) { - return []; - } - switch (this.paymentSource.type) { - case PaymentMethodType.Card: - return ["bwi-credit-card"]; - case PaymentMethodType.BankAccount: - case PaymentMethodType.Check: - return ["bwi-billing"]; - case PaymentMethodType.PayPal: - return ["bwi-paypal text-primary"]; - default: - return []; - } + return total * (this.discountPercentageFromSub / 100); } resolvePlanName(productTier: ProductTierType) { @@ -1064,9 +983,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } } - onFocus(index: number) { + async onFocus(index: number) { this.focusedIndex = index; - this.selectPlan(this.selectableProducts[index]); + await this.selectPlan(this.selectableProducts[index]); } isCardDisabled(index: number): boolean { @@ -1078,58 +997,44 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return index; } - private refreshSalesTax(): void { - if ( - this.taxInformation === undefined || - !this.taxInformation.country || - !this.taxInformation.postalCode - ) { + private async refreshSalesTax(): Promise { + if (this.billingFormGroup.controls.billingAddress.invalid && !this.billingAddress) { return; } - const request: PreviewOrganizationInvoiceRequest = { - organizationId: this.organizationId, - passwordManager: { - additionalStorage: 0, - plan: this.selectedPlan?.type, - seats: this.sub.seats, - }, - taxInformation: { - postalCode: this.taxInformation.postalCode, - country: this.taxInformation.country, - taxId: this.taxInformation.taxId, - }, + const getPlanFromLegacyEnum = (planType: PlanType): OrganizationSubscriptionPlan => { + switch (planType) { + case PlanType.FamiliesAnnually: + return { tier: "families", cadence: "annually" }; + case PlanType.TeamsMonthly: + return { tier: "teams", cadence: "monthly" }; + case PlanType.TeamsAnnually: + return { tier: "teams", cadence: "annually" }; + case PlanType.EnterpriseMonthly: + return { tier: "enterprise", cadence: "monthly" }; + case PlanType.EnterpriseAnnually: + return { tier: "enterprise", cadence: "annually" }; + } }; - if (this.organization.useSecretsManager) { - request.secretsManager = { - seats: this.sub.smSeats, - additionalMachineAccounts: - this.sub.smServiceAccounts - this.sub.plan.SecretsManager.baseServiceAccount, - }; - } + const billingAddress = this.billingFormGroup.controls.billingAddress.valid + ? getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress) + : this.billingAddress; - this.taxService - .previewOrganizationInvoice(request) - .then((invoice) => { - this.estimatedTax = invoice.taxAmount; - }) - .catch((error) => { - const translatedMessage = this.i18nService.t(error.message); - this.toastService.showToast({ - title: "", - variant: "error", - message: - !translatedMessage || translatedMessage === "" ? error.message : translatedMessage, - }); - }); + const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange( + this.organizationId, + getPlanFromLegacyEnum(this.selectedPlan.type), + billingAddress, + ); + + this.estimatedTax = taxAmounts.tax; } protected canUpdatePaymentInformation(): boolean { return ( this.upgradeRequiresPaymentMethod || this.showPayment || - this.isPaymentSourceEmpty() || + !this.paymentMethod || this.isSubscriptionCanceled ); } @@ -1146,4 +1051,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return this.i18nService.t("upgrade"); } } + + get supportsTaxId() { + return this.formGroup.value.productTier !== ProductTierType.Families; + } + + getCardBrandIcon = () => getCardBrandIcon(this.paymentMethod); } diff --git a/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts b/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts index 692791db855..5c8df483587 100644 --- a/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts @@ -11,7 +11,6 @@ import { WebPlatformUtilsService } from "../../core/web-platform-utils.service"; import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component"; import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component"; import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component"; -import { OrganizationPaymentMethodComponent } from "./payment-method/organization-payment-method.component"; const routes: Routes = [ { @@ -26,17 +25,6 @@ const routes: Routes = [ : OrganizationSubscriptionCloudComponent, data: { titleId: "subscription" }, }, - { - path: "payment-method", - component: OrganizationPaymentMethodComponent, - canActivate: [ - organizationPermissionsGuard((org) => org.canEditPaymentMethods), - organizationIsUnmanaged, - ], - data: { - titleId: "paymentMethod", - }, - }, { path: "payment-details", component: OrganizationPaymentDetailsComponent, diff --git a/apps/web/src/app/billing/organizations/organization-billing.module.ts b/apps/web/src/app/billing/organizations/organization-billing.module.ts index 707a854de02..90ba04c4fa4 100644 --- a/apps/web/src/app/billing/organizations/organization-billing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing.module.ts @@ -17,7 +17,6 @@ import { OrganizationBillingRoutingModule } from "./organization-billing-routing import { OrganizationPlansComponent } from "./organization-plans.component"; import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component"; import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component"; -import { OrganizationPaymentMethodComponent } from "./payment-method/organization-payment-method.component"; import { SecretsManagerAdjustSubscriptionComponent } from "./sm-adjust-subscription.component"; import { SecretsManagerSubscribeStandaloneComponent } from "./sm-subscribe-standalone.component"; import { SubscriptionHiddenComponent } from "./subscription-hidden.component"; @@ -45,7 +44,6 @@ import { SubscriptionStatusComponent } from "./subscription-status.component"; SecretsManagerSubscribeStandaloneComponent, SubscriptionHiddenComponent, SubscriptionStatusComponent, - OrganizationPaymentMethodComponent, ], }) export class OrganizationBillingModule {} diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 3b765927c3c..6234fc6e6e3 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -404,17 +404,16 @@

{{ paymentDesc }}

- - - + + } + + > +
{{ "passwordManagerPlanPrice" | i18n }}: {{ passwordManagerSubtotal | currency: "USD $" }} diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 820bee950eb..cbeedc454dc 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -11,10 +11,9 @@ import { } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; -import { Subject, firstValueFrom, takeUntil } from "rxjs"; +import { firstValueFrom, merge, Subject, takeUntil } from "rxjs"; import { debounceTime, map, switchMap } from "rxjs/operators"; -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { @@ -32,24 +31,12 @@ import { ProviderOrganizationCreateRequest } from "@bitwarden/common/admin-conso import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { - PaymentMethodType, - PlanSponsorshipType, - PlanType, - ProductTierType, -} from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; -import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request"; -import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request"; +import { PlanSponsorshipType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -59,10 +46,20 @@ import { OrgKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { + OrganizationSubscriptionPlan, + SubscriberBillingClient, + TaxClient, +} from "@bitwarden/web-vault/app/billing/clients"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { tokenizablePaymentMethodToLegacyEnum } from "@bitwarden/web-vault/app/billing/payment/types"; import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module"; import { BillingSharedModule, secretsManagerSubscribeFormFactory } from "../shared"; -import { PaymentComponent } from "../shared/payment/payment.component"; interface OnSuccessArgs { organizationId: string; @@ -78,11 +75,16 @@ const Allowed2020PlansForLegacyProviders = [ @Component({ selector: "app-organization-plans", templateUrl: "organization-plans.component.html", - imports: [BillingSharedModule, OrganizationCreateModule], + imports: [ + BillingSharedModule, + OrganizationCreateModule, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, + ], + providers: [SubscriberBillingClient, TaxClient], }) export class OrganizationPlansComponent implements OnInit, OnDestroy { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent; + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; @Input() organizationId?: string; @Input() showFree = true; @@ -105,8 +107,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { private _productTier = ProductTierType.Free; - protected taxInformation: TaxInformation; - @Input() get plan(): PlanType { return this._plan; @@ -135,10 +135,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { secretsManagerSubscription = secretsManagerSubscribeFormFactory(this.formBuilder); - selfHostedForm = this.formBuilder.group({ - file: [null, [Validators.required]], - }); - formGroup = this.formBuilder.group({ name: [""], billingEmail: ["", [Validators.email]], @@ -152,6 +148,11 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { secretsManager: this.secretsManagerSubscription, }); + billingFormGroup = this.formBuilder.group({ + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), + }); + passwordManagerPlans: PlanResponse[]; secretsManagerPlans: PlanResponse[]; organization: Organization; @@ -179,10 +180,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { private organizationApiService: OrganizationApiServiceAbstraction, private providerApiService: ProviderApiServiceAbstraction, private toastService: ToastService, - private configService: ConfigService, - private billingApiService: BillingApiServiceAbstraction, - private taxService: TaxServiceAbstraction, private accountService: AccountService, + private subscriberBillingClient: SubscriberBillingClient, + private taxClient: TaxClient, ) { this.selfHosted = this.platformUtilsService.isSelfHost(); } @@ -199,9 +199,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { ); this.billing = await this.organizationApiService.getBilling(this.organizationId); this.sub = await this.organizationApiService.getSubscription(this.organizationId); - this.taxInformation = await this.organizationApiService.getTaxInfo(this.organizationId); - } else if (!this.selfHosted) { - this.taxInformation = await this.apiService.getTaxInfo(); + const billingAddress = await this.subscriberBillingClient.getBillingAddress({ + type: "organization", + data: this.organization, + }); + this.billingFormGroup.controls.billingAddress.patchValue({ + ...billingAddress, + taxId: billingAddress?.taxId?.value, + }); } if (!this.selfHosted) { @@ -268,15 +273,17 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.loading = false; - this.formGroup.valueChanges.pipe(debounceTime(1000), takeUntil(this.destroy$)).subscribe(() => { - this.refreshSalesTax(); - }); - - this.secretsManagerForm.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe(() => { - this.refreshSalesTax(); - }); + merge( + this.formGroup.valueChanges, + this.billingFormGroup.valueChanges, + this.secretsManagerForm.valueChanges, + ) + .pipe( + debounceTime(1000), + switchMap(async () => await this.refreshSalesTax()), + takeUntil(this.destroy$), + ) + .subscribe(); if (this.enableSecretsManagerByDefault && this.selectedSecretsManagerPlan) { this.secretsManagerSubscription.patchValue({ @@ -587,34 +594,13 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.changedProduct(); } - protected changedCountry(): void { - this.paymentComponent.showBankAccount = this.taxInformation?.country === "US"; - if ( - !this.paymentComponent.showBankAccount && - this.paymentComponent.selected === PaymentMethodType.BankAccount - ) { - this.paymentComponent.select(PaymentMethodType.Card); - } - } - - protected onTaxInformationChanged(event: TaxInformation): void { - this.taxInformation = event; - this.changedCountry(); - this.refreshSalesTax(); - } - protected cancel(): void { this.onCanceled.emit(); } - protected setSelectedFile(event: Event): void { - const fileInputEl = event.target; - this.selectedFile = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null; - } - submit = async () => { - if (this.taxComponent && !this.taxComponent.validate()) { - this.taxComponent.markAllAsTouched(); + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { return; } @@ -688,46 +674,54 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } } - private refreshSalesTax(): void { - if (!this.taxComponent.validate()) { + private async refreshSalesTax(): Promise { + if (this.billingFormGroup.controls.billingAddress.invalid) { return; } - const request: PreviewOrganizationInvoiceRequest = { - organizationId: this.organizationId, - passwordManager: { - additionalStorage: this.formGroup.controls.additionalStorage.value, - plan: this.formGroup.controls.plan.value, - sponsoredPlan: this.planSponsorshipType, - seats: this.formGroup.controls.additionalSeats.value, - }, - taxInformation: { - postalCode: this.taxInformation.postalCode, - country: this.taxInformation.country, - taxId: this.taxInformation.taxId, - }, + const getPlanFromLegacyEnum = (): OrganizationSubscriptionPlan => { + switch (this.formGroup.value.plan) { + case PlanType.FamiliesAnnually: + return { tier: "families", cadence: "annually" }; + case PlanType.TeamsMonthly: + return { tier: "teams", cadence: "monthly" }; + case PlanType.TeamsAnnually: + return { tier: "teams", cadence: "annually" }; + case PlanType.EnterpriseMonthly: + return { tier: "enterprise", cadence: "monthly" }; + case PlanType.EnterpriseAnnually: + return { tier: "enterprise", cadence: "annually" }; + } }; - if (this.secretsManagerForm.controls.enabled.value === true) { - request.secretsManager = { - seats: this.secretsManagerForm.controls.userSeats.value, - additionalMachineAccounts: this.secretsManagerForm.controls.additionalServiceAccounts.value, - }; - } + const billingAddress = getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress); - this.taxService - .previewOrganizationInvoice(request) - .then((invoice) => { - this.estimatedTax = invoice.taxAmount; - this.total = invoice.totalAmount; - }) - .catch((error) => { - this.toastService.showToast({ - title: "", - variant: "error", - message: this.i18nService.t(error.message), - }); - }); + const passwordManagerSeats = + this.formGroup.value.productTier === ProductTierType.Families + ? 1 + : this.formGroup.value.additionalSeats; + + const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + { + ...getPlanFromLegacyEnum(), + passwordManager: { + seats: passwordManagerSeats, + additionalStorage: this.formGroup.value.additionalStorage, + sponsored: false, + }, + secretsManager: this.formGroup.value.secretsManager.enabled + ? { + seats: this.secretsManagerForm.value.userSeats, + additionalServiceAccounts: this.secretsManagerForm.value.additionalServiceAccounts, + standalone: false, + } + : undefined, + }, + billingAddress, + ); + + this.estimatedTax = taxAmounts.tax; + this.total = taxAmounts.total; } private async updateOrganization() { @@ -738,21 +732,24 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.selectedPlan.PasswordManager.hasPremiumAccessOption && this.formGroup.controls.premiumAccessAddon.value; request.planType = this.selectedPlan.type; - request.billingAddressCountry = this.taxInformation?.country; - request.billingAddressPostalCode = this.taxInformation?.postalCode; + request.billingAddressCountry = this.billingFormGroup.value.billingAddress.country; + request.billingAddressPostalCode = this.billingFormGroup.value.billingAddress.postalCode; // Secrets Manager this.buildSecretsManagerRequest(request); if (this.upgradeRequiresPaymentMethod) { - const updatePaymentMethodRequest = new UpdatePaymentMethodRequest(); - updatePaymentMethodRequest.paymentSource = await this.paymentComponent.tokenize(); - updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From( - this.taxInformation, - ); - await this.billingApiService.updateOrganizationPaymentMethod( - this.organizationId, - updatePaymentMethodRequest, + if (this.billingFormGroup.invalid) { + return; + } + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + await this.subscriberBillingClient.updatePaymentMethod( + { type: "organization", data: this.organization }, + paymentMethod, + { + country: this.billingFormGroup.value.billingAddress.country, + postalCode: this.billingFormGroup.value.billingAddress.postalCode, + }, ); } @@ -791,23 +788,31 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { if (this.selectedPlan.type === PlanType.Free) { request.planType = PlanType.Free; } else { - const { type, token } = await this.paymentComponent.tokenize(); + if (this.billingFormGroup.invalid) { + return; + } - request.paymentToken = token; - request.paymentMethodType = type; + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + + const billingAddress = getBillingAddressFromForm( + this.billingFormGroup.controls.billingAddress, + ); + + request.paymentToken = paymentMethod.token; + request.paymentMethodType = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); request.additionalSeats = this.formGroup.controls.additionalSeats.value; request.additionalStorageGb = this.formGroup.controls.additionalStorage.value; request.premiumAccessAddon = this.selectedPlan.PasswordManager.hasPremiumAccessOption && this.formGroup.controls.premiumAccessAddon.value; request.planType = this.selectedPlan.type; - request.billingAddressPostalCode = this.taxInformation?.postalCode; - request.billingAddressCountry = this.taxInformation?.country; - request.taxIdNumber = this.taxInformation?.taxId; - request.billingAddressLine1 = this.taxInformation?.line1; - request.billingAddressLine2 = this.taxInformation?.line2; - request.billingAddressCity = this.taxInformation?.city; - request.billingAddressState = this.taxInformation?.state; + request.billingAddressPostalCode = billingAddress.postalCode; + request.billingAddressCountry = billingAddress.country; + request.taxIdNumber = billingAddress.taxId?.value; + request.billingAddressLine1 = billingAddress.line1; + request.billingAddressLine2 = billingAddress.line2; + request.billingAddressCity = billingAddress.city; + request.billingAddressState = billingAddress.state; } // Secrets Manager diff --git a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts index 47742ba0a88..b2bf27e726a 100644 --- a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts +++ b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts @@ -1,15 +1,11 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; +import { ActivatedRoute } from "@angular/router"; import { BehaviorSubject, - catchError, combineLatest, - EMPTY, filter, firstValueFrom, - from, lastValueFrom, - map, merge, Observable, of, @@ -22,15 +18,13 @@ import { withLatestFrom, } from "rxjs"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { getById } from "@bitwarden/common/platform/misc"; import { DialogService } from "@bitwarden/components"; import { CommandDefinition, MessageListener } from "@bitwarden/messaging"; import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; @@ -54,13 +48,6 @@ import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/type import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -class RedirectError { - constructor( - public path: string[], - public relativeTo: ActivatedRoute, - ) {} -} - type View = { organization: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; @@ -93,24 +80,12 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { switchMap((userId) => this.organizationService .organizations$(userId) - .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), + .pipe(getById(this.activatedRoute.snapshot.params.organizationId)), ), filter((organization): organization is Organization => !!organization), ); private load$: Observable = this.organization$.pipe( - switchMap((organization) => - this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => { - if (!managePaymentDetailsOutsideCheckout) { - throw new RedirectError(["../payment-method"], this.activatedRoute); - } - return organization; - }), - ), - ), mapOrganizationToSubscriber, switchMap(async (organization) => { const getTaxIdWarning = firstValueFrom( @@ -132,14 +107,6 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { taxIdWarning, }; }), - catchError((error: unknown) => { - if (error instanceof RedirectError) { - return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( - switchMap(() => EMPTY), - ); - } - throw error; - }), ); view$: Observable = merge( @@ -159,7 +126,6 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { private messageListener: MessageListener, private organizationService: OrganizationService, private organizationWarningsService: OrganizationWarningsService, - private router: Router, private subscriberBillingClient: SubscriberBillingClient, ) {} diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html deleted file mode 100644 index ab31147e916..00000000000 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - {{ "loading" | i18n }} - - - - -

- {{ accountCreditHeaderText }} -

-

{{ Math.abs(accountCredit) | currency: "$" }}

-

{{ "creditAppliedDesc" | i18n }}

- -
- - -

{{ "paymentMethod" | i18n }}

-

{{ "noPaymentMethod" | i18n }}

- - - -

- - {{ paymentSource.description }} - - {{ "unverified" | i18n }} -

-
- -

- {{ "paymentChargedWithUnpaidSubscription" | i18n }} -

-
-
-
diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts deleted file mode 100644 index 4106ee4f9cd..00000000000 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { Location } from "@angular/common"; -import { Component, OnDestroy } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, firstValueFrom, from, lastValueFrom, map, switchMap } from "rxjs"; - -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { - OrganizationService, - getOrganizationById, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; -import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; -import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; -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 { SyncService } from "@bitwarden/common/platform/sync"; -import { DialogService, ToastService } from "@bitwarden/components"; - -import { BillingNotificationService } from "../../services/billing-notification.service"; -import { - AddCreditDialogResult, - openAddCreditDialog, -} from "../../shared/add-credit-dialog.component"; -import { - AdjustPaymentDialogComponent, - AdjustPaymentDialogResultType, -} from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component"; -import { - TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, - TrialPaymentDialogComponent, -} from "../../shared/trial-payment-dialog/trial-payment-dialog.component"; - -@Component({ - templateUrl: "./organization-payment-method.component.html", - standalone: false, -}) -export class OrganizationPaymentMethodComponent implements OnDestroy { - organizationId!: string; - isUnpaid = false; - accountCredit?: number; - paymentSource?: PaymentSourceResponse; - subscriptionStatus?: string; - organization?: Organization; - organizationSubscriptionResponse?: OrganizationSubscriptionResponse; - - loading = true; - - protected readonly Math = Math; - launchPaymentModalAutomatically = false; - - protected taxInformation?: TaxInformation; - - constructor( - private activatedRoute: ActivatedRoute, - private billingApiService: BillingApiServiceAbstraction, - protected organizationApiService: OrganizationApiServiceAbstraction, - private dialogService: DialogService, - private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, - private router: Router, - private toastService: ToastService, - private location: Location, - private organizationService: OrganizationService, - private accountService: AccountService, - protected syncService: SyncService, - private billingNotificationService: BillingNotificationService, - private configService: ConfigService, - ) { - combineLatest([ - this.activatedRoute.params, - this.configService.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout), - ]) - .pipe( - switchMap(([{ organizationId }, managePaymentDetailsOutsideCheckout]) => { - if (this.platformUtilsService.isSelfHost()) { - return from(this.router.navigate(["/settings/subscription"])); - } - - if (managePaymentDetailsOutsideCheckout) { - return from( - this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute }), - ); - } - - this.organizationId = organizationId; - return from(this.load()); - }), - takeUntilDestroyed(), - ) - .subscribe(); - - const state = this.router.getCurrentNavigation()?.extras?.state; - // In case the above state is undefined or null, we use redundantState - const redundantState: any = location.getState(); - const queryParam = this.activatedRoute.snapshot.queryParamMap.get( - "launchPaymentModalAutomatically", - ); - if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { - this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; - } else if ( - redundantState && - Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically") - ) { - this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically; - } else { - this.launchPaymentModalAutomatically = queryParam === "true"; - } - } - ngOnDestroy(): void { - this.launchPaymentModalAutomatically = false; - } - - protected addAccountCredit = async (): Promise => { - if (this.subscriptionStatus === "trialing") { - const hasValidBillingAddress = await this.checkBillingAddressForTrialingOrg(); - if (!hasValidBillingAddress) { - return; - } - } - const dialogRef = openAddCreditDialog(this.dialogService, { - data: { - organizationId: this.organizationId, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); - - if (result === AddCreditDialogResult.Added) { - await this.load(); - } - }; - - protected load = async (): Promise => { - this.loading = true; - try { - const { accountCredit, paymentSource, subscriptionStatus, taxInformation } = - await this.billingApiService.getOrganizationPaymentMethod(this.organizationId); - this.accountCredit = accountCredit; - this.paymentSource = paymentSource; - this.subscriptionStatus = subscriptionStatus; - this.taxInformation = taxInformation; - this.isUnpaid = this.subscriptionStatus === "unpaid"; - - if (this.organizationId) { - const organizationSubscriptionPromise = this.organizationApiService.getSubscription( - this.organizationId, - ); - - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - - if (!userId) { - throw new Error("User ID is not found"); - } - - const organizationPromise = await firstValueFrom( - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.organizationId)), - ); - - [this.organizationSubscriptionResponse, this.organization] = await Promise.all([ - organizationSubscriptionPromise, - organizationPromise, - ]); - - if (!this.organization) { - throw new Error("Organization is not found"); - } - if (!this.paymentSource) { - throw new Error("Payment source is not found"); - } - } - // If the flag `launchPaymentModalAutomatically` is set to true, - // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. - // This delay ensures that any prior UI/rendering operations complete before triggering the modal. - if (this.launchPaymentModalAutomatically) { - window.setTimeout(async () => { - await this.changePayment(); - this.launchPaymentModalAutomatically = false; - this.location.replaceState(this.location.path(), "", {}); - }, 800); - } - } catch (error) { - this.billingNotificationService.handleError(error); - } finally { - this.loading = false; - } - }; - - protected updatePaymentMethod = async (): Promise => { - const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, { - data: { - initialPaymentMethod: this.paymentSource?.type, - organizationId: this.organizationId, - productTier: this.organization?.productTierType, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); - - if (result === AdjustPaymentDialogResultType.Submitted) { - await this.load(); - } - }; - - changePayment = async () => { - const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { - data: { - organizationId: this.organizationId, - subscription: this.organizationSubscriptionResponse!, - productTierType: this.organization!.productTierType, - }, - }); - const result = await lastValueFrom(dialogRef.closed); - if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { - this.location.replaceState(this.location.path(), "", {}); - if (this.launchPaymentModalAutomatically && !this.organization?.enabled) { - await this.syncService.fullSync(true); - } - this.launchPaymentModalAutomatically = false; - await this.load(); - } - }; - - protected verifyBankAccount = async (request: VerifyBankAccountRequest): Promise => { - await this.billingApiService.verifyOrganizationBankAccount(this.organizationId, request); - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("verifiedBankAccount"), - }); - }; - - protected get accountCreditHeaderText(): string { - const hasAccountCredit = this.accountCredit && this.accountCredit > 0; - const key = hasAccountCredit ? "accountCredit" : "accountBalance"; - return this.i18nService.t(key); - } - - protected get paymentSourceClasses() { - if (this.paymentSource == null) { - return []; - } - switch (this.paymentSource.type) { - case PaymentMethodType.Card: - return ["bwi-credit-card"]; - case PaymentMethodType.BankAccount: - case PaymentMethodType.Check: - return ["bwi-billing"]; - case PaymentMethodType.PayPal: - return ["bwi-paypal text-primary"]; - default: - return []; - } - } - - protected get subscriptionIsUnpaid(): boolean { - return this.subscriptionStatus === "unpaid"; - } - - protected get updatePaymentSourceButtonText(): string { - const key = this.paymentSource == null ? "addPaymentMethod" : "changePaymentMethod"; - return this.i18nService.t(key); - } - - private async checkBillingAddressForTrialingOrg(): Promise { - const hasBillingAddress = this.taxInformation != null; - if (!hasBillingAddress) { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("billingAddressRequiredToAddCredit"), - }); - return false; - } - return true; - } -} diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts index c7a297cc28b..53f72558089 100644 --- a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts @@ -15,8 +15,6 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; -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 { DialogRef, DialogService } from "@bitwarden/components"; import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients"; @@ -35,7 +33,6 @@ import { TaxIdWarningTypes } from "@bitwarden/web-vault/app/billing/warnings/typ describe("OrganizationWarningsService", () => { let service: OrganizationWarningsService; - let configService: MockProxy; let dialogService: MockProxy; let i18nService: MockProxy; let organizationApiService: MockProxy; @@ -57,7 +54,6 @@ describe("OrganizationWarningsService", () => { }); beforeEach(() => { - configService = mock(); dialogService = mock(); i18nService = mock(); organizationApiService = mock(); @@ -94,7 +90,6 @@ describe("OrganizationWarningsService", () => { TestBed.configureTestingModule({ providers: [ OrganizationWarningsService, - { provide: ConfigService, useValue: configService }, { provide: DialogService, useValue: dialogService }, { provide: I18nService, useValue: i18nService }, { provide: OrganizationApiServiceAbstraction, useValue: organizationApiService }, @@ -466,7 +461,6 @@ describe("OrganizationWarningsService", () => { } as OrganizationWarningsResponse); dialogService.openSimpleDialog.mockResolvedValue(true); - configService.getFeatureFlag.mockResolvedValue(false); router.navigate.mockResolvedValue(true); service.showInactiveSubscriptionDialog$(organization).subscribe({ @@ -478,11 +472,8 @@ describe("OrganizationWarningsService", () => { acceptButtonText: "Continue", cancelButtonText: "Close", }); - expect(configService.getFeatureFlag).toHaveBeenCalledWith( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); expect(router.navigate).toHaveBeenCalledWith( - ["organizations", "org-id-123", "billing", "payment-method"], + ["organizations", "org-id-123", "billing", "payment-details"], { state: { launchPaymentModalAutomatically: true } }, ); done(); @@ -497,7 +488,6 @@ describe("OrganizationWarningsService", () => { } as OrganizationWarningsResponse); dialogService.openSimpleDialog.mockResolvedValue(true); - configService.getFeatureFlag.mockResolvedValue(true); router.navigate.mockResolvedValue(true); service.showInactiveSubscriptionDialog$(organization).subscribe({ @@ -522,7 +512,6 @@ describe("OrganizationWarningsService", () => { service.showInactiveSubscriptionDialog$(organization).subscribe({ complete: () => { expect(dialogService.openSimpleDialog).toHaveBeenCalled(); - expect(configService.getFeatureFlag).not.toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled(); done(); }, diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts index c6bb1bc231b..46a34def28b 100644 --- a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts @@ -16,8 +16,6 @@ import { take } from "rxjs/operators"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -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 { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; @@ -53,7 +51,6 @@ export class OrganizationWarningsService { taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable(); constructor( - private configService: ConfigService, private dialogService: DialogService, private i18nService: I18nService, private organizationApiService: OrganizationApiServiceAbstraction, @@ -196,14 +193,8 @@ export class OrganizationWarningsService { cancelButtonText: this.i18nService.t("close"), }); if (confirmed) { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - const route = managePaymentDetailsOutsideCheckout - ? "payment-details" - : "payment-method"; await this.router.navigate( - ["organizations", `${organization.id}`, "billing", route], + ["organizations", `${organization.id}`, "billing", "payment-details"], { state: { launchPaymentModalAutomatically: true }, }, diff --git a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts index c33d805aed7..5f5e3442935 100644 --- a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts @@ -5,7 +5,7 @@ import { DialogService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; import { BitwardenSubscriber } from "../../types"; -import { MaskedPaymentMethod } from "../types"; +import { getCardBrandIcon, MaskedPaymentMethod } from "../types"; import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component"; @@ -40,9 +40,9 @@ import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dial } @case ("card") {

- @let brandIcon = getBrandIconForCard(); - @if (brandIcon !== null) { - + @let cardBrandIcon = getCardBrandIcon(); + @if (cardBrandIcon !== null) { + } @else { } @@ -74,16 +74,6 @@ export class DisplayPaymentMethodComponent { @Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null; @Output() updated = new EventEmitter(); - protected availableCardIcons: Record = { - amex: "card-amex", - diners: "card-diners-club", - discover: "card-discover", - jcb: "card-jcb", - mastercard: "card-mastercard", - unionpay: "card-unionpay", - visa: "card-visa", - }; - constructor(private dialogService: DialogService) {} changePaymentMethod = async (): Promise => { @@ -100,13 +90,5 @@ export class DisplayPaymentMethodComponent { } }; - protected getBrandIconForCard = (): string | null => { - if (this.paymentMethod?.type !== "card") { - return null; - } - - return this.paymentMethod.brand in this.availableCardIcons - ? this.availableCardIcons[this.paymentMethod.brand] - : null; - }; + protected getCardBrandIcon = () => getCardBrandIcon(this.paymentMethod); } diff --git a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts index de2f2f94497..6e356097d32 100644 --- a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts @@ -11,10 +11,7 @@ import { ToastService, } from "@bitwarden/components"; import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; -import { - BillingAddress, - getTaxIdTypeForCountry, -} from "@bitwarden/web-vault/app/billing/payment/types"; +import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types"; import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; import { TaxIdWarningType, @@ -22,7 +19,10 @@ import { } from "@bitwarden/web-vault/app/billing/warnings/types"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { EnterBillingAddressComponent } from "./enter-billing-address.component"; +import { + EnterBillingAddressComponent, + getBillingAddressFromForm, +} from "./enter-billing-address.component"; type DialogParams = { subscriber: BitwardenSubscriber; @@ -104,13 +104,7 @@ export class EditBillingAddressDialogComponent { return; } - const { taxId, ...addressFields } = this.formGroup.getRawValue(); - - const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country) : null; - - const billingAddress = taxIdType - ? { ...addressFields, taxId: { code: taxIdType.code, value: taxId! } } - : { ...addressFields, taxId: null }; + const billingAddress = getBillingAddressFromForm(this.formGroup); const result = await this.billingClient.updateBillingAddress( this.dialogParams.subscriber, diff --git a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts index 7659b7ed5ca..3f68c12c897 100644 --- a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts @@ -24,6 +24,17 @@ export interface BillingAddressControls { export type BillingAddressFormGroup = FormGroup>; +export const getBillingAddressFromForm = (formGroup: BillingAddressFormGroup): BillingAddress => + getBillingAddressFromControls(formGroup.getRawValue()); + +export const getBillingAddressFromControls = (controls: BillingAddressControls) => { + const { taxId, ...addressFields } = controls; + const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country) : null; + return taxIdType + ? { ...addressFields, taxId: { code: taxIdType.code, value: taxId! } } + : { ...addressFields, taxId: null }; +}; + type Scenario = | { type: "checkout"; @@ -67,54 +78,56 @@ type Scenario = />

-
- - {{ "address1" | i18n }} - - -
-
- - {{ "address2" | i18n }} - - -
-
- - {{ "cityTown" | i18n }} - - -
-
- - {{ "stateProvince" | i18n }} - - -
+ @if (scenario.type === "update") { +
+ + {{ "address1" | i18n }} + + +
+
+ + {{ "address2" | i18n }} + + +
+
+ + {{ "cityTown" | i18n }} + + +
+
+ + {{ "stateProvince" | i18n }} + + +
+ } @if (supportsTaxId$ | async) {
@@ -175,7 +188,7 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy { this.supportsTaxId$ = this.group.controls.country.valueChanges.pipe( startWith(this.group.value.country ?? this.selectableCountries[0].value), map((country) => { - if (!this.scenario.supportsTaxId) { + if (!this.scenario.supportsTaxId || country === "US") { return false; } diff --git a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts index 93c45b873fe..4af5226e7ee 100644 --- a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts @@ -8,7 +8,6 @@ import { PopoverModule, ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; import { BillingServicesModule, BraintreeService, StripeService } from "../../services"; -import { PaymentLabelComponent } from "../../shared/payment/payment-label.component"; import { isTokenizablePaymentMethod, selectableCountries, @@ -16,6 +15,8 @@ import { TokenizedPaymentMethod, } from "../types"; +import { PaymentLabelComponent } from "./payment-label.component"; + type PaymentMethodOption = TokenizablePaymentMethod | "accountCredit"; type PaymentMethodFormGroup = FormGroup<{ @@ -102,7 +103,7 @@ type PaymentMethodFormGroup = FormGroup<{ - - - - -
- - - - - - - - - - - - - - - -
diff --git a/apps/web/src/app/billing/shared/add-credit-dialog.component.ts b/apps/web/src/app/billing/shared/add-credit-dialog.component.ts deleted file mode 100644 index cdf72168acf..00000000000 --- a/apps/web/src/app/billing/shared/add-credit-dialog.component.ts +++ /dev/null @@ -1,191 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { firstValueFrom, map } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; - -export interface AddCreditDialogData { - organizationId: string; -} - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum AddCreditDialogResult { - Added = "added", - Cancelled = "cancelled", -} - -export type PayPalConfig = { - businessId?: string; - buttonAction?: string; -}; - -@Component({ - templateUrl: "add-credit-dialog.component.html", - standalone: false, -}) -export class AddCreditDialogComponent implements OnInit { - @ViewChild("ppButtonForm", { read: ElementRef, static: true }) ppButtonFormRef: ElementRef; - - paymentMethodType = PaymentMethodType; - ppButtonFormAction: string; - ppButtonBusinessId: string; - ppButtonCustomField: string; - ppLoading = false; - subject: string; - returnUrl: string; - organizationId: string; - - private userId: string; - private name: string; - private email: string; - private region: string; - - protected DialogResult = AddCreditDialogResult; - protected formGroup = new FormGroup({ - method: new FormControl(PaymentMethodType.PayPal), - creditAmount: new FormControl(null, [Validators.required]), - }); - - constructor( - private dialogRef: DialogRef, - @Inject(DIALOG_DATA) protected data: AddCreditDialogData, - private accountService: AccountService, - private apiService: ApiService, - private platformUtilsService: PlatformUtilsService, - private organizationService: OrganizationService, - private logService: LogService, - private configService: ConfigService, - ) { - this.organizationId = data.organizationId; - const payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig; - this.ppButtonFormAction = payPalConfig.buttonAction; - this.ppButtonBusinessId = payPalConfig.businessId; - } - - async ngOnInit() { - if (this.organizationId != null) { - if (this.creditAmount == null) { - this.creditAmount = "0.00"; - } - this.ppButtonCustomField = "organization_id:" + this.organizationId; - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - const org = await firstValueFrom( - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.organizationId)), - ); - if (org != null) { - this.subject = org.name; - this.name = org.name; - } - } else { - if (this.creditAmount == null) { - this.creditAmount = "0.00"; - } - const [userId, email] = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])), - ); - this.userId = userId; - this.subject = email; - this.email = this.subject; - this.ppButtonCustomField = "user_id:" + this.userId; - } - this.region = await firstValueFrom(this.configService.cloudRegion$); - this.ppButtonCustomField += ",account_credit:1"; - this.ppButtonCustomField += `,region:${this.region}`; - this.returnUrl = window.location.href; - } - - get creditAmount() { - return this.formGroup.value.creditAmount; - } - set creditAmount(value: string) { - this.formGroup.get("creditAmount").setValue(value); - } - - get method() { - return this.formGroup.value.method; - } - - submit = async () => { - if (this.creditAmount == null || this.creditAmount === "") { - return; - } - - if (this.method === PaymentMethodType.PayPal) { - this.ppButtonFormRef.nativeElement.submit(); - this.ppLoading = true; - return; - } - if (this.method === PaymentMethodType.BitPay) { - const req = new BitPayInvoiceRequest(); - req.email = this.email; - req.name = this.name; - req.credit = true; - req.amount = this.creditAmountNumber; - req.organizationId = this.organizationId; - req.userId = this.userId; - req.returnUrl = this.returnUrl; - const bitPayUrl: string = await this.apiService.postBitPayInvoice(req); - this.platformUtilsService.launchUri(bitPayUrl); - return; - } - this.dialogRef.close(AddCreditDialogResult.Added); - }; - - formatAmount() { - try { - if (this.creditAmount != null && this.creditAmount !== "") { - const floatAmount = Math.abs(parseFloat(this.creditAmount)); - if (floatAmount > 0) { - this.creditAmount = parseFloat((Math.round(floatAmount * 100) / 100).toString()) - .toFixed(2) - .toString(); - return; - } - } - } catch (e) { - this.logService.error(e); - } - this.creditAmount = ""; - } - - get creditAmountNumber(): number { - if (this.creditAmount != null && this.creditAmount !== "") { - try { - return parseFloat(this.creditAmount); - } catch (e) { - this.logService.error(e); - } - } - return null; - } -} - -/** - * Strongly typed helper to open a AddCreditDialog - * @param dialogService Instance of the dialog service that will be used to open the dialog - * @param config Configuration for the dialog - */ -export function openAddCreditDialog( - dialogService: DialogService, - config: DialogConfig, -) { - return dialogService.open(AddCreditDialogComponent, config); -} diff --git a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.html b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.html deleted file mode 100644 index 9c70908af8e..00000000000 --- a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - diff --git a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts deleted file mode 100644 index 9944085488f..00000000000 --- a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts +++ /dev/null @@ -1,225 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, forwardRef, Inject, OnInit, ViewChild } from "@angular/core"; - -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { PaymentMethodType, ProductTierType } from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; -import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request"; -import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request"; -import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { - DIALOG_DATA, - DialogConfig, - DialogRef, - DialogService, - ToastService, -} from "@bitwarden/components"; - -import { PaymentComponent } from "../payment/payment.component"; - -export interface AdjustPaymentDialogParams { - initialPaymentMethod?: PaymentMethodType | null; - organizationId?: string; - productTier?: ProductTierType; - providerId?: string; -} - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum AdjustPaymentDialogResultType { - Closed = "closed", - Submitted = "submitted", -} - -@Component({ - templateUrl: "./adjust-payment-dialog.component.html", - standalone: false, -}) -export class AdjustPaymentDialogComponent implements OnInit { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(forwardRef(() => ManageTaxInformationComponent)) - taxInfoComponent: ManageTaxInformationComponent; - - protected readonly PaymentMethodType = PaymentMethodType; - protected readonly ResultType = AdjustPaymentDialogResultType; - - protected dialogHeader: string; - protected initialPaymentMethod: PaymentMethodType; - protected organizationId?: string; - protected productTier?: ProductTierType; - protected providerId?: string; - - protected loading = true; - - protected taxInformation: TaxInformation; - - constructor( - private apiService: ApiService, - private billingApiService: BillingApiServiceAbstraction, - private organizationApiService: OrganizationApiServiceAbstraction, - @Inject(DIALOG_DATA) protected dialogParams: AdjustPaymentDialogParams, - private dialogRef: DialogRef, - private i18nService: I18nService, - private toastService: ToastService, - ) { - const key = this.dialogParams.initialPaymentMethod ? "changePaymentMethod" : "addPaymentMethod"; - this.dialogHeader = this.i18nService.t(key); - this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card; - this.organizationId = this.dialogParams.organizationId; - this.productTier = this.dialogParams.productTier; - this.providerId = this.dialogParams.providerId; - } - - ngOnInit(): void { - if (this.organizationId) { - this.organizationApiService - .getTaxInfo(this.organizationId) - .then((response: TaxInfoResponse) => { - this.taxInformation = TaxInformation.from(response); - this.toggleBankAccount(); - }) - .catch(() => { - this.taxInformation = new TaxInformation(); - }) - .finally(() => { - this.loading = false; - }); - } else if (this.providerId) { - this.billingApiService - .getProviderTaxInformation(this.providerId) - .then((response) => { - this.taxInformation = TaxInformation.from(response); - this.toggleBankAccount(); - }) - .catch(() => { - this.taxInformation = new TaxInformation(); - }) - .finally(() => { - this.loading = false; - }); - } else { - this.apiService - .getTaxInfo() - .then((response: TaxInfoResponse) => { - this.taxInformation = TaxInformation.from(response); - }) - .catch(() => { - this.taxInformation = new TaxInformation(); - }) - .finally(() => { - this.loading = false; - }); - } - } - - taxInformationChanged(event: TaxInformation) { - this.taxInformation = event; - this.toggleBankAccount(); - } - - toggleBankAccount = () => { - if (this.taxInformation.country === "US") { - this.paymentComponent.showBankAccount = !!this.organizationId || !!this.providerId; - } else { - this.paymentComponent.showBankAccount = false; - if (this.paymentComponent.selected === PaymentMethodType.BankAccount) { - this.paymentComponent.select(PaymentMethodType.Card); - } - } - }; - - submit = async (): Promise => { - if (!this.taxInfoComponent.validate()) { - this.taxInfoComponent.markAllAsTouched(); - return; - } - - try { - if (this.organizationId) { - await this.updateOrganizationPaymentMethod(); - } else if (this.providerId) { - await this.updateProviderPaymentMethod(); - } else { - await this.updatePremiumUserPaymentMethod(); - } - - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("updatedPaymentMethod"), - }); - - this.dialogRef.close(AdjustPaymentDialogResultType.Submitted); - } catch (error) { - const msg = typeof error == "object" ? error.message : error; - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t(msg) || msg, - }); - } - }; - - private updateOrganizationPaymentMethod = async () => { - const paymentSource = await this.paymentComponent.tokenize(); - - const request = new UpdatePaymentMethodRequest(); - request.paymentSource = paymentSource; - request.taxInformation = ExpandedTaxInfoUpdateRequest.From(this.taxInformation); - - await this.billingApiService.updateOrganizationPaymentMethod(this.organizationId, request); - }; - - private updatePremiumUserPaymentMethod = async () => { - const { type, token } = await this.paymentComponent.tokenize(); - - const request = new PaymentRequest(); - request.paymentMethodType = type; - request.paymentToken = token; - request.country = this.taxInformation.country; - request.postalCode = this.taxInformation.postalCode; - request.taxId = this.taxInformation.taxId; - request.state = this.taxInformation.state; - request.line1 = this.taxInformation.line1; - request.line2 = this.taxInformation.line2; - request.city = this.taxInformation.city; - request.state = this.taxInformation.state; - await this.apiService.postAccountPayment(request); - }; - - private updateProviderPaymentMethod = async () => { - const paymentSource = await this.paymentComponent.tokenize(); - - const request = new UpdatePaymentMethodRequest(); - request.paymentSource = paymentSource; - request.taxInformation = ExpandedTaxInfoUpdateRequest.From(this.taxInformation); - - await this.billingApiService.updateProviderPaymentMethod(this.providerId, request); - }; - - protected get showTaxIdField(): boolean { - if (this.organizationId) { - switch (this.productTier) { - case ProductTierType.Free: - case ProductTierType.Families: - return false; - default: - return true; - } - } else { - return !!this.providerId; - } - } - - static open = ( - dialogService: DialogService, - dialogConfig: DialogConfig, - ) => - dialogService.open(AdjustPaymentDialogComponent, dialogConfig); -} diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index 7322f047551..fb593b39328 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -1,46 +1,40 @@ import { NgModule } from "@angular/core"; import { BannerModule } from "@bitwarden/components"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, +} from "@bitwarden/web-vault/app/billing/payment/components"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; -import { AddCreditDialogComponent } from "./add-credit-dialog.component"; -import { AdjustPaymentDialogComponent } from "./adjust-payment-dialog/adjust-payment-dialog.component"; import { AdjustStorageDialogComponent } from "./adjust-storage-dialog/adjust-storage-dialog.component"; import { BillingHistoryComponent } from "./billing-history.component"; import { OffboardingSurveyComponent } from "./offboarding-survey.component"; -import { PaymentComponent } from "./payment/payment.component"; -import { PaymentMethodComponent } from "./payment-method.component"; import { PlanCardComponent } from "./plan-card/plan-card.component"; import { PricingSummaryComponent } from "./pricing-summary/pricing-summary.component"; import { IndividualSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/individual-self-hosting-license-uploader.component"; import { OrganizationSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/organization-self-hosting-license-uploader.component"; import { SecretsManagerSubscribeComponent } from "./sm-subscribe.component"; -import { TaxInfoComponent } from "./tax-info.component"; import { TrialPaymentDialogComponent } from "./trial-payment-dialog/trial-payment-dialog.component"; import { UpdateLicenseDialogComponent } from "./update-license-dialog.component"; import { UpdateLicenseComponent } from "./update-license.component"; -import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-account.component"; @NgModule({ imports: [ SharedModule, - TaxInfoComponent, HeaderModule, BannerModule, - PaymentComponent, - VerifyBankAccountComponent, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, ], declarations: [ - AddCreditDialogComponent, BillingHistoryComponent, - PaymentMethodComponent, SecretsManagerSubscribeComponent, UpdateLicenseComponent, UpdateLicenseDialogComponent, OffboardingSurveyComponent, - AdjustPaymentDialogComponent, AdjustStorageDialogComponent, IndividualSelfHostingLicenseUploaderComponent, OrganizationSelfHostingLicenseUploaderComponent, @@ -50,14 +44,11 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac ], exports: [ SharedModule, - TaxInfoComponent, BillingHistoryComponent, SecretsManagerSubscribeComponent, UpdateLicenseComponent, UpdateLicenseDialogComponent, OffboardingSurveyComponent, - VerifyBankAccountComponent, - PaymentComponent, IndividualSelfHostingLicenseUploaderComponent, OrganizationSelfHostingLicenseUploaderComponent, ], diff --git a/apps/web/src/app/billing/shared/index.ts b/apps/web/src/app/billing/shared/index.ts index 54ab5bc0a2a..466d1d3e586 100644 --- a/apps/web/src/app/billing/shared/index.ts +++ b/apps/web/src/app/billing/shared/index.ts @@ -1,4 +1,2 @@ export * from "./billing-shared.module"; -export * from "./payment-method.component"; export * from "./sm-subscribe.component"; -export * from "./tax-info.component"; diff --git a/apps/web/src/app/billing/shared/payment-method.component.html b/apps/web/src/app/billing/shared/payment-method.component.html deleted file mode 100644 index 81ed7e5a631..00000000000 --- a/apps/web/src/app/billing/shared/payment-method.component.html +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - -

{{ "paymentMethod" | i18n }}

- - - - {{ "loading" | i18n }} - - - -

- {{ (isCreditBalance ? "accountCredit" : "accountBalance") | i18n }} -

-

{{ creditOrBalance | currency: "$" }}

-

{{ "creditAppliedDesc" | i18n }}

- -
- -

{{ "paymentMethod" | i18n }}

-

{{ "noPaymentMethod" | i18n }}

- - -

- {{ "verifyBankAccountDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }} -

-
- - {{ "amountX" | i18n: "1" }} - - $0. - - - {{ "amountX" | i18n: "2" }} - - $0. - - -
-
-

- - {{ paymentSource.description }} -

-
- -

- {{ "paymentChargedWithUnpaidSubscription" | i18n }} -

-
-
-
diff --git a/apps/web/src/app/billing/shared/payment-method.component.ts b/apps/web/src/app/billing/shared/payment-method.component.ts deleted file mode 100644 index b6431843b83..00000000000 --- a/apps/web/src/app/billing/shared/payment-method.component.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { Location } from "@angular/common"; -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { FormBuilder, FormControl, Validators } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom, lastValueFrom, map } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { - OrganizationService, - getOrganizationById, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.response"; -import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; -import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank.request"; -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 { SyncService } from "@bitwarden/common/platform/sync"; -import { DialogService, ToastService } from "@bitwarden/components"; - -import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component"; -import { - AdjustPaymentDialogComponent, - AdjustPaymentDialogResultType, -} from "./adjust-payment-dialog/adjust-payment-dialog.component"; - -@Component({ - templateUrl: "payment-method.component.html", - standalone: false, -}) -export class PaymentMethodComponent implements OnInit, OnDestroy { - loading = false; - firstLoaded = false; - billing?: BillingPaymentResponse; - org?: OrganizationSubscriptionResponse; - sub?: SubscriptionResponse; - paymentMethodType = PaymentMethodType; - organizationId?: string; - isUnpaid = false; - organization?: Organization; - - verifyBankForm = this.formBuilder.group({ - amount1: new FormControl(0, [ - Validators.required, - Validators.max(99), - Validators.min(0), - ]), - amount2: new FormControl(0, [ - Validators.required, - Validators.max(99), - Validators.min(0), - ]), - }); - - launchPaymentModalAutomatically = false; - constructor( - protected apiService: ApiService, - protected organizationApiService: OrganizationApiServiceAbstraction, - protected i18nService: I18nService, - protected platformUtilsService: PlatformUtilsService, - private router: Router, - private location: Location, - private route: ActivatedRoute, - private formBuilder: FormBuilder, - private dialogService: DialogService, - private toastService: ToastService, - private organizationService: OrganizationService, - private accountService: AccountService, - protected syncService: SyncService, - private configService: ConfigService, - ) { - const state = this.router.getCurrentNavigation()?.extras?.state; - // In case the above state is undefined or null, we use redundantState - const redundantState: any = location.getState(); - if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { - this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; - } else if ( - redundantState && - Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically") - ) { - this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically; - } else { - this.launchPaymentModalAutomatically = false; - } - } - - async ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.params.subscribe(async (params) => { - if (params.organizationId) { - this.organizationId = params.organizationId; - } else if (this.platformUtilsService.isSelfHost()) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/settings/subscription"]); - return; - } - - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - - if (managePaymentDetailsOutsideCheckout) { - await this.router.navigate(["../payment-details"], { relativeTo: this.route }); - } - - await this.load(); - this.firstLoaded = true; - }); - } - - load = async () => { - if (this.loading) { - return; - } - this.loading = true; - if (this.forOrganization) { - const billingPromise = this.organizationApiService.getBilling(this.organizationId!); - const organizationSubscriptionPromise = this.organizationApiService.getSubscription( - this.organizationId!, - ); - - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - - if (!userId) { - throw new Error("User ID is not found"); - } - - const organizationPromise = await firstValueFrom( - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.organizationId!)), - ); - - [this.billing, this.org, this.organization] = await Promise.all([ - billingPromise, - organizationSubscriptionPromise, - organizationPromise, - ]); - } else { - const billingPromise = this.apiService.getUserBillingPayment(); - const subPromise = this.apiService.getUserSubscription(); - - [this.billing, this.sub] = await Promise.all([billingPromise, subPromise]); - } - // TODO: Eslint upgrade. Please resolve this since the ?? does nothing - // eslint-disable-next-line no-constant-binary-expression - this.isUnpaid = this.subscription?.status === "unpaid" ?? false; - this.loading = false; - // If the flag `launchPaymentModalAutomatically` is set to true, - // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. - // This delay ensures that any prior UI/rendering operations complete before triggering the modal. - if (this.launchPaymentModalAutomatically) { - window.setTimeout(async () => { - await this.changePayment(); - this.launchPaymentModalAutomatically = false; - this.location.replaceState(this.location.path(), "", {}); - }, 800); - } - }; - - addCredit = async () => { - if (this.forOrganization) { - const dialogRef = openAddCreditDialog(this.dialogService, { - data: { - organizationId: this.organizationId!, - }, - }); - const result = await lastValueFrom(dialogRef.closed); - if (result === AddCreditDialogResult.Added) { - await this.load(); - } - } - }; - - changePayment = async () => { - const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, { - data: { - organizationId: this.organizationId, - initialPaymentMethod: this.paymentSource !== null ? this.paymentSource.type : null, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); - - if (result === AdjustPaymentDialogResultType.Submitted) { - this.location.replaceState(this.location.path(), "", {}); - if (this.launchPaymentModalAutomatically && !this.organization?.enabled) { - await this.syncService.fullSync(true); - } - this.launchPaymentModalAutomatically = false; - await this.load(); - } - }; - - verifyBank = async () => { - if (this.loading || !this.forOrganization) { - return; - } - - const request = new VerifyBankRequest(); - request.amount1 = this.verifyBankForm.value.amount1!; - request.amount2 = this.verifyBankForm.value.amount2!; - await this.organizationApiService.verifyBank(this.organizationId!, request); - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("verifiedBankAccount"), - }); - await this.load(); - }; - - get isCreditBalance() { - return this.billing == null || this.billing.balance <= 0; - } - - get creditOrBalance() { - return Math.abs(this.billing != null ? this.billing.balance : 0); - } - - get paymentSource() { - return this.billing != null ? this.billing.paymentSource : null; - } - - get forOrganization() { - return this.organizationId != null; - } - - get paymentSourceClasses() { - if (this.paymentSource == null) { - return []; - } - switch (this.paymentSource.type) { - case PaymentMethodType.Card: - return ["bwi-credit-card"]; - case PaymentMethodType.BankAccount: - case PaymentMethodType.Check: - return ["bwi-billing"]; - case PaymentMethodType.PayPal: - return ["bwi-paypal text-primary"]; - default: - return []; - } - } - - get subscription() { - return this.sub?.subscription ?? this.org?.subscription ?? null; - } - - ngOnDestroy(): void { - this.launchPaymentModalAutomatically = false; - } -} diff --git a/apps/web/src/app/billing/shared/payment/payment-label.component.html b/apps/web/src/app/billing/shared/payment/payment-label.component.html deleted file mode 100644 index a931b0524e3..00000000000 --- a/apps/web/src/app/billing/shared/payment/payment-label.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - -
- - - ({{ "required" | i18n }}) - -
diff --git a/apps/web/src/app/billing/shared/payment/payment.component.html b/apps/web/src/app/billing/shared/payment/payment.component.html deleted file mode 100644 index d1356c20854..00000000000 --- a/apps/web/src/app/billing/shared/payment/payment.component.html +++ /dev/null @@ -1,149 +0,0 @@ -
-
- - - - - {{ "creditCard" | i18n }} - - - - - - {{ "bankAccount" | i18n }} - - - - - - {{ "payPal" | i18n }} - - - - - - {{ "accountCredit" | i18n }} - - - -
- - -
-
- - {{ "number" | i18n }} - -
-
-
- Visa, MasterCard, Discover, AmEx, JCB, Diners Club, UnionPay -
-
- - {{ "expiration" | i18n }} - -
-
-
- - {{ "securityCodeSlashCVV" | i18n }} - - - - -
-
-
-
- - - - {{ "requiredToVerifyBankAccountWithStripe" | i18n }} - -
- - {{ "routingNumber" | i18n }} - - - - {{ "accountNumber" | i18n }} - - - - {{ "accountHolderName" | i18n }} - - - - {{ "bankAccountType" | i18n }} - - - - - - -
-
- - -
-
- {{ "paypalClickSubmit" | i18n }} -
-
- - - - {{ "makeSureEnoughCredit" | i18n }} - - - -
diff --git a/apps/web/src/app/billing/shared/payment/payment.component.ts b/apps/web/src/app/billing/shared/payment/payment.component.ts deleted file mode 100644 index 08476e9952f..00000000000 --- a/apps/web/src/app/billing/shared/payment/payment.component.ts +++ /dev/null @@ -1,215 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { Subject } from "rxjs"; -import { takeUntil } from "rxjs/operators"; - -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { TokenizedPaymentSourceRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-source.request"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; - -import { SharedModule } from "../../../shared"; -import { BillingServicesModule, BraintreeService, StripeService } from "../../services"; - -import { PaymentLabelComponent } from "./payment-label.component"; - -/** - * Render a form that allows the user to enter their payment method, tokenize it against one of our payment providers and, - * optionally, submit it using the {@link onSubmit} function if it is provided. - */ -@Component({ - selector: "app-payment", - templateUrl: "./payment.component.html", - imports: [BillingServicesModule, SharedModule, PaymentLabelComponent], -}) -export class PaymentComponent implements OnInit, OnDestroy { - /** Show account credit as a payment option. */ - @Input() showAccountCredit: boolean = true; - /** Show bank account as a payment option. */ - @Input() showBankAccount: boolean = true; - /** Show PayPal as a payment option. */ - @Input() showPayPal: boolean = true; - - /** The payment method selected by default when the component renders. */ - @Input() private initialPaymentMethod: PaymentMethodType = PaymentMethodType.Card; - /** If provided, will be invoked with the tokenized payment source during form submission. */ - @Input() protected onSubmit?: (request: TokenizedPaymentSourceRequest) => Promise; - - @Input() private bankAccountWarningOverride?: string; - - @Output() submitted = new EventEmitter(); - - private destroy$ = new Subject(); - - protected formGroup = new FormGroup({ - paymentMethod: new FormControl(null), - bankInformation: new FormGroup({ - routingNumber: new FormControl("", [Validators.required]), - accountNumber: new FormControl("", [Validators.required]), - accountHolderName: new FormControl("", [Validators.required]), - accountHolderType: new FormControl("", [Validators.required]), - }), - }); - - protected PaymentMethodType = PaymentMethodType; - - constructor( - private billingApiService: BillingApiServiceAbstraction, - private braintreeService: BraintreeService, - private i18nService: I18nService, - private stripeService: StripeService, - ) {} - - ngOnInit(): void { - this.formGroup.controls.paymentMethod.patchValue(this.initialPaymentMethod); - - this.stripeService.loadStripe( - { - cardNumber: "#stripe-card-number", - cardExpiry: "#stripe-card-expiry", - cardCvc: "#stripe-card-cvc", - }, - this.initialPaymentMethod === PaymentMethodType.Card, - ); - - if (this.showPayPal) { - this.braintreeService.loadBraintree( - "#braintree-container", - this.initialPaymentMethod === PaymentMethodType.PayPal, - ); - } - - this.formGroup - .get("paymentMethod") - .valueChanges.pipe(takeUntil(this.destroy$)) - .subscribe((type) => { - this.onPaymentMethodChange(type); - }); - } - - /** Programmatically select the provided payment method. */ - select = (paymentMethod: PaymentMethodType) => { - this.formGroup.get("paymentMethod").patchValue(paymentMethod); - }; - - protected submit = async () => { - const { type, token } = await this.tokenize(); - await this.onSubmit?.({ type, token }); - this.submitted.emit(type); - }; - - validate = () => { - if (!this.usingBankAccount) { - return true; - } - - this.formGroup.controls.bankInformation.markAllAsTouched(); - return this.formGroup.controls.bankInformation.valid; - }; - - /** - * Tokenize the payment method information entered by the user against one of our payment providers. - * - * - {@link PaymentMethodType.Card} => [Stripe.confirmCardSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_card_setup} - * - {@link PaymentMethodType.BankAccount} => [Stripe.confirmUsBankAccountSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_us_bank_account_setup} - * - {@link PaymentMethodType.PayPal} => [Braintree.requestPaymentMethod]{@link https://braintree.github.io/braintree-web-drop-in/docs/current/Dropin.html#requestPaymentMethod} - * */ - async tokenize(): Promise<{ type: PaymentMethodType; token: string }> { - const type = this.selected; - - if (this.usingStripe) { - const clientSecret = await this.billingApiService.createSetupIntent(type); - - if (this.usingBankAccount) { - this.formGroup.markAllAsTouched(); - if (this.formGroup.valid) { - const token = await this.stripeService.setupBankAccountPaymentMethod(clientSecret, { - accountHolderName: this.formGroup.value.bankInformation.accountHolderName, - routingNumber: this.formGroup.value.bankInformation.routingNumber, - accountNumber: this.formGroup.value.bankInformation.accountNumber, - accountHolderType: this.formGroup.value.bankInformation.accountHolderType, - }); - return { - type, - token, - }; - } else { - throw "Invalid input provided. Please ensure all required fields are filled out correctly and try again."; - } - } - - if (this.usingCard) { - const token = await this.stripeService.setupCardPaymentMethod(clientSecret); - return { - type, - token, - }; - } - } - - if (this.usingPayPal) { - const token = await this.braintreeService.requestPaymentMethod(); - return { - type, - token, - }; - } - - if (this.usingAccountCredit) { - return { - type: PaymentMethodType.Credit, - token: null, - }; - } - - return null; - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - this.stripeService.unloadStripe(); - if (this.showPayPal) { - this.braintreeService.unloadBraintree(); - } - } - - private onPaymentMethodChange(type: PaymentMethodType): void { - switch (type) { - case PaymentMethodType.Card: { - this.stripeService.mountElements(); - break; - } - case PaymentMethodType.PayPal: { - this.braintreeService.createDropin(); - break; - } - } - } - - get selected(): PaymentMethodType { - return this.formGroup.value.paymentMethod; - } - - protected get usingAccountCredit(): boolean { - return this.selected === PaymentMethodType.Credit; - } - - protected get usingBankAccount(): boolean { - return this.selected === PaymentMethodType.BankAccount; - } - - protected get usingCard(): boolean { - return this.selected === PaymentMethodType.Card; - } - - protected get usingPayPal(): boolean { - return this.selected === PaymentMethodType.PayPal; - } - - private get usingStripe(): boolean { - return this.usingBankAccount || this.usingCard; - } -} diff --git a/apps/web/src/app/billing/shared/tax-info.component.html b/apps/web/src/app/billing/shared/tax-info.component.html deleted file mode 100644 index ca2ae046f6e..00000000000 --- a/apps/web/src/app/billing/shared/tax-info.component.html +++ /dev/null @@ -1,83 +0,0 @@ -
-
-
- - {{ "country" | i18n }} - - - - -
-
- - {{ "zipPostalCode" | i18n }} - - -
-
- - {{ "address1" | i18n }} - - -
-
- - {{ "address2" | i18n }} - - -
-
- - {{ "cityTown" | i18n }} - - -
-
- - {{ "stateProvince" | i18n }} - - -
-
- - {{ "taxIdNumber" | i18n }} - - -
-
-
diff --git a/apps/web/src/app/billing/shared/tax-info.component.ts b/apps/web/src/app/billing/shared/tax-info.component.ts deleted file mode 100644 index 35c4a3fcc4e..00000000000 --- a/apps/web/src/app/billing/shared/tax-info.component.ts +++ /dev/null @@ -1,199 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { ActivatedRoute } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; -import { debounceTime } from "rxjs/operators"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { CountryListItem } from "@bitwarden/common/billing/models/domain"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; - -import { SharedModule } from "../../shared"; - -/** - * @deprecated Use `ManageTaxInformationComponent` instead. - */ -@Component({ - selector: "app-tax-info", - templateUrl: "tax-info.component.html", - imports: [SharedModule], -}) -export class TaxInfoComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); - - @Input() trialFlow = false; - @Output() countryChanged = new EventEmitter(); - @Output() taxInformationChanged: EventEmitter = new EventEmitter(); - - taxFormGroup = new FormGroup({ - country: new FormControl(null, [Validators.required]), - postalCode: new FormControl(null, [Validators.required]), - taxId: new FormControl(null), - line1: new FormControl(null), - line2: new FormControl(null), - city: new FormControl(null), - state: new FormControl(null), - }); - - protected isTaxSupported: boolean; - - loading = true; - organizationId: string; - providerId: string; - countryList: CountryListItem[] = this.taxService.getCountries(); - - constructor( - private apiService: ApiService, - private route: ActivatedRoute, - private logService: LogService, - private organizationApiService: OrganizationApiServiceAbstraction, - private taxService: TaxServiceAbstraction, - ) {} - - get country(): string { - return this.taxFormGroup.controls.country.value; - } - - get postalCode(): string { - return this.taxFormGroup.controls.postalCode.value; - } - - get taxId(): string { - return this.taxFormGroup.controls.taxId.value; - } - - get line1(): string { - return this.taxFormGroup.controls.line1.value; - } - - get line2(): string { - return this.taxFormGroup.controls.line2.value; - } - - get city(): string { - return this.taxFormGroup.controls.city.value; - } - - get state(): string { - return this.taxFormGroup.controls.state.value; - } - - get showTaxIdField(): boolean { - return !!this.organizationId; - } - - async ngOnInit() { - // Provider setup - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - this.route.queryParams.subscribe((params) => { - this.providerId = params.providerId; - }); - - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent?.parent?.params.subscribe(async (params) => { - this.organizationId = params.organizationId; - if (this.organizationId) { - try { - const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId); - if (taxInfo) { - this.taxFormGroup.controls.taxId.setValue(taxInfo.taxId); - this.taxFormGroup.controls.state.setValue(taxInfo.state); - this.taxFormGroup.controls.line1.setValue(taxInfo.line1); - this.taxFormGroup.controls.line2.setValue(taxInfo.line2); - this.taxFormGroup.controls.city.setValue(taxInfo.city); - this.taxFormGroup.controls.postalCode.setValue(taxInfo.postalCode); - this.taxFormGroup.controls.country.setValue(taxInfo.country); - } - } catch (e) { - this.logService.error(e); - } - } else { - try { - const taxInfo = await this.apiService.getTaxInfo(); - if (taxInfo) { - this.taxFormGroup.controls.postalCode.setValue(taxInfo.postalCode); - this.taxFormGroup.controls.country.setValue(taxInfo.country); - } - } catch (e) { - this.logService.error(e); - } - } - - this.isTaxSupported = await this.taxService.isCountrySupported( - this.taxFormGroup.controls.country.value, - ); - - this.countryChanged.emit(); - }); - - this.taxFormGroup.controls.country.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe((value) => { - this.taxService - .isCountrySupported(this.taxFormGroup.controls.country.value) - .then((isSupported) => { - this.isTaxSupported = isSupported; - }) - .catch(() => { - this.isTaxSupported = false; - }) - .finally(() => { - if (!this.isTaxSupported) { - this.taxFormGroup.controls.taxId.setValue(null); - this.taxFormGroup.controls.line1.setValue(null); - this.taxFormGroup.controls.line2.setValue(null); - this.taxFormGroup.controls.city.setValue(null); - this.taxFormGroup.controls.state.setValue(null); - } - - this.countryChanged.emit(); - }); - this.taxInformationChanged.emit(); - }); - - this.taxFormGroup.controls.postalCode.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe(() => { - this.taxInformationChanged.emit(); - }); - - this.taxFormGroup.controls.taxId.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe(() => { - this.taxInformationChanged.emit(); - }); - - this.loading = false; - } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - submitTaxInfo(): Promise { - this.taxFormGroup.updateValueAndValidity(); - this.taxFormGroup.markAllAsTouched(); - - const request = new ExpandedTaxInfoUpdateRequest(); - request.country = this.country; - request.postalCode = this.postalCode; - request.taxId = this.taxId; - request.line1 = this.line1; - request.line2 = this.line2; - request.city = this.city; - request.state = this.state; - - return this.organizationId - ? this.organizationApiService.updateTaxInfo( - this.organizationId, - request as ExpandedTaxInfoUpdateRequest, - ) - : this.apiService.putTaxInfo(request); - } -} diff --git a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html index dbd2899c9e0..1b416eae1bc 100644 --- a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html +++ b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html @@ -86,17 +86,13 @@

{{ "paymentMethod" | i18n }}

- - + + + + (); protected initialPaymentMethod: PaymentMethodType; - protected taxInformation!: TaxInformation; protected readonly ResultType = TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE; pricingSummaryData!: PricingSummaryData; + formGroup = new FormGroup({ + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), + }); + + private destroy$ = new Subject(); + constructor( @Inject(DIALOG_DATA) private dialogParams: TrialPaymentDialogParams, private dialogRef: DialogRef, @@ -93,8 +110,9 @@ export class TrialPaymentDialogComponent implements OnInit { private pricingSummaryService: PricingSummaryService, private apiService: ApiService, private toastService: ToastService, - private billingApiService: BillingApiServiceAbstraction, private organizationBillingApiServiceAbstraction: OrganizationBillingApiServiceAbstraction, + private subscriberBillingClient: SubscriberBillingClient, + private taxClient: TaxClient, ) { this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card; } @@ -134,19 +152,48 @@ export class TrialPaymentDialogComponent implements OnInit { : PlanInterval.Monthly; } - const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId); - this.taxInformation = TaxInformation.from(taxInfo); + const billingAddress = await this.subscriberBillingClient.getBillingAddress({ + type: "organization", + data: this.organization, + }); - this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData( - this.currentPlan, - this.sub, - this.organization, - this.selectedInterval, - this.taxInformation, - this.isSecretsManagerTrial(), - ); + if (billingAddress) { + const { taxId, ...location } = billingAddress; + + this.formGroup.controls.billingAddress.patchValue({ + ...location, + taxId: taxId ? taxId.value : null, + }); + } + + await this.refreshPricingSummary(); this.plans = await this.apiService.getPlans(); + + combineLatest([ + this.formGroup.controls.billingAddress.controls.country.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.controls.country.value), + ), + this.formGroup.controls.billingAddress.controls.postalCode.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.controls.postalCode.value), + ), + this.formGroup.controls.billingAddress.controls.taxId.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.controls.taxId.value), + ), + ]) + .pipe( + debounceTime(500), + switchMap(() => { + return this.refreshPricingSummary(); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } static open = ( @@ -175,14 +222,7 @@ export class TrialPaymentDialogComponent implements OnInit { await this.selectPlan(); - this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData( - this.currentPlan, - this.sub, - this.organization, - this.selectedInterval, - this.taxInformation, - this.isSecretsManagerTrial(), - ); + await this.refreshPricingSummary(); } protected async selectPlan() { @@ -202,7 +242,7 @@ export class TrialPaymentDialogComponent implements OnInit { this.currentPlan = filteredPlans[0]; } try { - await this.refreshSalesTax(); + await this.refreshPricingSummary(); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const translatedMessage = this.i18nService.t(errorMessage); @@ -214,72 +254,57 @@ export class TrialPaymentDialogComponent implements OnInit { } } - protected get showTaxIdField(): boolean { - switch (this.currentPlan.productTier) { - case ProductTierType.Free: - case ProductTierType.Families: - return false; - default: - return true; - } - } - - private async refreshSalesTax(): Promise { - if ( - this.taxInformation === undefined || - !this.taxInformation.country || - !this.taxInformation.postalCode - ) { - return; - } - - const request: PreviewOrganizationInvoiceRequest = { - organizationId: this.organizationId, - passwordManager: { - additionalStorage: 0, - plan: this.currentPlan?.type, - seats: this.sub.seats, - }, - taxInformation: { - postalCode: this.taxInformation.postalCode, - country: this.taxInformation.country, - taxId: this.taxInformation.taxId, - }, - }; - - if (this.organization.useSecretsManager) { - request.secretsManager = { - seats: this.sub.smSeats ?? 0, - additionalMachineAccounts: - (this.sub.smServiceAccounts ?? 0) - - (this.sub.plan.SecretsManager?.baseServiceAccount ?? 0), - }; - } - + private refreshPricingSummary = async () => { + const estimatedTax = await this.getEstimatedTax(); this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData( this.currentPlan, this.sub, this.organization, this.selectedInterval, - this.taxInformation, this.isSecretsManagerTrial(), + estimatedTax, ); - } + }; - async taxInformationChanged(event: TaxInformation) { - this.taxInformation = event; - this.toggleBankAccount(); - await this.refreshSalesTax(); - } + private getEstimatedTax = async () => { + if (this.formGroup.controls.billingAddress.invalid) { + return 0; + } - toggleBankAccount = () => { - this.paymentComponent.showBankAccount = this.taxInformation.country === "US"; + const cadence = + this.currentPlan.productTier !== ProductTierType.Families + ? this.currentPlan.isAnnual + ? "annually" + : "monthly" + : null; - if ( - !this.paymentComponent.showBankAccount && - this.paymentComponent.selected === PaymentMethodType.BankAccount - ) { - this.paymentComponent.select(PaymentMethodType.Card); + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + + const getTierFromLegacyEnum = (organization: Organization) => { + switch (organization.productTierType) { + case ProductTierType.Families: + return "families"; + case ProductTierType.Teams: + return "teams"; + case ProductTierType.Enterprise: + return "enterprise"; + } + }; + + const tier = getTierFromLegacyEnum(this.organization); + + if (tier && cadence) { + const costs = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange( + this.organization.id, + { + tier, + cadence, + }, + billingAddress, + ); + return costs.tax; + } else { + return 0; } }; @@ -292,15 +317,24 @@ export class TrialPaymentDialogComponent implements OnInit { } async onSubscribe(): Promise { - if (!this.taxComponent.validate()) { - this.taxComponent.markAllAsTouched(); + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { + return; } + try { - await this.updateOrganizationPaymentMethod( - this.organizationId, - this.paymentComponent, - this.taxInformation, - ); + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + return; + } + + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + + const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization }; + await Promise.all([ + this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null), + this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress), + ]); if (this.currentPlan.type !== this.sub.planType) { const changePlanRequest = new ChangePlanFrequencyRequest(); @@ -332,20 +366,6 @@ export class TrialPaymentDialogComponent implements OnInit { } } - private async updateOrganizationPaymentMethod( - organizationId: string, - paymentComponent: PaymentComponent, - taxInformation: TaxInformation, - ): Promise { - const paymentSource = await paymentComponent.tokenize(); - - const request = new UpdatePaymentMethodRequest(); - request.paymentSource = paymentSource; - request.taxInformation = ExpandedTaxInfoUpdateRequest.From(taxInformation); - - await this.billingApiService.updateOrganizationPaymentMethod(organizationId, request); - } - resolvePlanName(productTier: ProductTierType): string { switch (productTier) { case ProductTierType.Enterprise: @@ -362,4 +382,11 @@ export class TrialPaymentDialogComponent implements OnInit { return this.i18nService.t("planNameFree"); } } + + get supportsTaxId() { + if (!this.organization) { + return false; + } + return this.organization.productTierType !== ProductTierType.Families; + } } diff --git a/apps/web/src/app/billing/shared/verify-bank-account/verify-bank-account.component.html b/apps/web/src/app/billing/shared/verify-bank-account/verify-bank-account.component.html deleted file mode 100644 index 1367e6e3082..00000000000 --- a/apps/web/src/app/billing/shared/verify-bank-account/verify-bank-account.component.html +++ /dev/null @@ -1,12 +0,0 @@ - -

{{ "verifyBankAccountWithStatementDescriptorInstructions" | i18n }}

-
- - {{ "descriptorCode" | i18n }} - - - -
-
diff --git a/apps/web/src/app/billing/shared/verify-bank-account/verify-bank-account.component.ts b/apps/web/src/app/billing/shared/verify-bank-account/verify-bank-account.component.ts deleted file mode 100644 index b7cdfbe60a2..00000000000 --- a/apps/web/src/app/billing/shared/verify-bank-account/verify-bank-account.component.ts +++ /dev/null @@ -1,34 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { FormBuilder, FormControl, Validators } from "@angular/forms"; - -import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; - -import { SharedModule } from "../../../shared"; - -@Component({ - selector: "app-verify-bank-account", - templateUrl: "./verify-bank-account.component.html", - imports: [SharedModule], -}) -export class VerifyBankAccountComponent { - @Input() onSubmit?: (request: VerifyBankAccountRequest) => Promise; - @Output() submitted = new EventEmitter(); - - protected formGroup = this.formBuilder.group({ - descriptorCode: new FormControl(null, [ - Validators.required, - Validators.minLength(6), - Validators.maxLength(6), - ]), - }); - - constructor(private formBuilder: FormBuilder) {} - - submit = async () => { - const request = new VerifyBankAccountRequest(this.formGroup.value.descriptorCode); - await this.onSubmit?.(request); - this.submitted.emit(); - }; -} diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html index c1a33a4c8df..7377fc45484 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html @@ -54,17 +54,7 @@ > diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts index 0b1ddda0c12..baccabdc763 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts @@ -30,13 +30,10 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ToastService } from "@bitwarden/components"; import { UserId } from "@bitwarden/user-core"; +import { Trial } from "@bitwarden/web-vault/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service"; -import { - OrganizationCreatedEvent, - SubscriptionProduct, - TrialOrganizationType, -} from "../../../billing/accounts/trial-initiation/trial-billing-step.component"; import { RouterService } from "../../../core/router.service"; +import { OrganizationCreatedEvent } from "../trial-billing-step/trial-billing-step.component"; import { VerticalStepperComponent } from "../vertical-stepper/vertical-stepper.component"; export type InitiationPath = @@ -95,7 +92,6 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { }); private destroy$ = new Subject(); - protected readonly SubscriptionProduct = SubscriptionProduct; protected readonly ProductType = ProductType; protected trialPaymentOptional$ = this.configService.getFeatureFlag$( FeatureFlag.TrialPaymentOptional, @@ -338,14 +334,6 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { } } - get trialOrganizationType(): TrialOrganizationType | null { - if (this.productTier === ProductTierType.Free) { - return null; - } - - return this.productTier; - } - readonly showBillingStep$ = this.trialPaymentOptional$.pipe( map((trialPaymentOptional) => { return ( @@ -434,4 +422,26 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { return null; }); } + + get trial(): Trial { + const product = + this.product === ProductType.PasswordManager ? "passwordManager" : "secretsManager"; + + const tier = + this.productTier === ProductTierType.Families + ? "families" + : this.productTier === ProductTierType.Teams + ? "teams" + : "enterprise"; + + return { + organization: { + name: this.orgInfoFormGroup.value.name!, + email: this.orgInfoFormGroup.value.billingEmail!, + }, + product, + tier, + length: this.trialLength, + }; + } } diff --git a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.html b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.html new file mode 100644 index 00000000000..51b7f0c7117 --- /dev/null +++ b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.html @@ -0,0 +1,87 @@ +@if (!(prices$ | async)) { + +} @else { + @let prices = prices$ | async; +
+
+ +
+

{{ "billingPlanLabel" | i18n }}

+ +
+ + + {{ "annual" | i18n }} - + {{ prices.annually | currency: "$" }} + /{{ "yr" | i18n }} + + +
+ @if (prices.monthly) { +
+ + + {{ "monthly" | i18n }} - + {{ prices.monthly | currency: "$" }} + /{{ "monthAbbr" | i18n }} + + +
+ } +
+
+ +
+

{{ "paymentType" | i18n }}

+ + + + @if (trial().length === 0) { + @let label = + trial().product === "passwordManager" + ? "passwordManagerPlanPrice" + : "secretsManagerPlanPrice"; +
+ @let selectionTaxAmounts = selectionCosts$ | async; +
+ {{ label | i18n }}: {{ selectionPrice$ | async | currency: "USD $" }} +
+ {{ "estimatedTax" | i18n }}: + {{ selectionTaxAmounts.tax | currency: "USD $" }} +
+
+
+

+ {{ "total" | i18n }}: + @let interval = formGroup.value.cadence === "annually" ? "year" : "month"; + {{ selectionTaxAmounts.total | currency: "USD $" }}/{{ interval | i18n }} +

+
+ } +
+ +
+ + +
+
+
+} + + + + {{ "loading" | i18n }} + diff --git a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts new file mode 100644 index 00000000000..0f185564c2e --- /dev/null +++ b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts @@ -0,0 +1,160 @@ +import { Component, input, OnDestroy, OnInit, output, ViewChild } from "@angular/core"; +import { FormControl, FormGroup } from "@angular/forms"; +import { + combineLatest, + debounceTime, + filter, + map, + Observable, + shareReplay, + startWith, + switchMap, + Subject, + firstValueFrom, +} from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ToastService } from "@bitwarden/components"; +import { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + BillingAddressControls, + EnterBillingAddressComponent, + EnterPaymentMethodComponent, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + Cadence, + Cadences, + Prices, + Trial, + TrialBillingStepService, +} from "@bitwarden/web-vault/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +export interface OrganizationCreatedEvent { + organizationId: string; + planDescription: string; +} + +@Component({ + selector: "app-trial-billing-step", + templateUrl: "./trial-billing-step.component.html", + imports: [EnterPaymentMethodComponent, EnterBillingAddressComponent, SharedModule], + providers: [TaxClient, TrialBillingStepService], +}) +export class TrialBillingStepComponent implements OnInit, OnDestroy { + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; + + protected trial = input.required(); + protected steppedBack = output(); + protected organizationCreated = output(); + + private destroy$ = new Subject(); + + protected prices$!: Observable; + + protected selectionPrice$!: Observable; + protected selectionCosts$!: Observable<{ + tax: number; + total: number; + }>; + protected selectionDescription$!: Observable; + + protected formGroup = new FormGroup({ + cadence: new FormControl(Cadences.Annually, { + nonNullable: true, + }), + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), + }); + + constructor( + private i18nService: I18nService, + private toastService: ToastService, + private trialBillingStepService: TrialBillingStepService, + ) {} + + async ngOnInit() { + const { product, tier } = this.trial(); + this.prices$ = this.trialBillingStepService.getPrices$(product, tier); + + const cadenceChanged = this.formGroup.controls.cadence.valueChanges.pipe( + startWith(Cadences.Annually), + ); + + this.selectionPrice$ = combineLatest([this.prices$, cadenceChanged]).pipe( + map(([prices, cadence]) => prices[cadence]), + filter((price): price is number => !!price), + ); + + this.selectionCosts$ = combineLatest([ + cadenceChanged, + this.formGroup.controls.billingAddress.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.value), + filter( + (billingAddress): billingAddress is BillingAddressControls => + !!billingAddress.country && !!billingAddress.postalCode, + ), + ), + ]).pipe( + debounceTime(500), + switchMap(([cadence, billingAddress]) => + this.trialBillingStepService.getCosts(product, tier, cadence, billingAddress), + ), + startWith({ + tax: 0, + total: 0, + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + this.selectionDescription$ = combineLatest([this.selectionPrice$, cadenceChanged]).pipe( + map(([price, cadence]) => { + switch (cadence) { + case Cadences.Annually: + return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`; + case Cadences.Monthly: + return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`; + } + }), + ); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + submit = async (): Promise => { + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { + return; + } + + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + return; + } + + const billingAddress = this.formGroup.controls.billingAddress.getRawValue(); + + const organization = await this.trialBillingStepService.startTrial( + this.trial(), + this.formGroup.value.cadence!, + billingAddress, + paymentMethod, + ); + + this.toastService.showToast({ + variant: "success", + title: this.i18nService.t("organizationCreated"), + message: this.i18nService.t("organizationReadyToGo"), + }); + + this.organizationCreated.emit({ + organizationId: organization.id, + planDescription: await firstValueFrom(this.selectionDescription$), + }); + }; + + protected stepBack = () => this.steppedBack.emit(); +} diff --git a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts new file mode 100644 index 00000000000..9e4f45ede92 --- /dev/null +++ b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts @@ -0,0 +1,209 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom, from, map, shareReplay } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { + OrganizationBillingServiceAbstraction, + SubscriptionInformation, +} from "@bitwarden/common/billing/abstractions"; +import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; +import { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + BillingAddressControls, + getBillingAddressFromControls, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + tokenizablePaymentMethodToLegacyEnum, + TokenizedPaymentMethod, +} from "@bitwarden/web-vault/app/billing/payment/types"; + +export const Tiers = { + Families: "families", + Teams: "teams", + Enterprise: "enterprise", +} as const; + +export const Cadences = { + Annually: "annually", + Monthly: "monthly", +} as const; + +export const Products = { + PasswordManager: "passwordManager", + SecretsManager: "secretsManager", +} as const; + +export type Tier = (typeof Tiers)[keyof typeof Tiers]; +export type Cadence = (typeof Cadences)[keyof typeof Cadences]; +export type Product = (typeof Products)[keyof typeof Products]; + +export type Prices = { + [Cadences.Annually]: number; + [Cadences.Monthly]?: number; +}; + +export interface Trial { + organization: { + name: string; + email: string; + }; + product: Product; + tier: Tier; + length: number; +} + +@Injectable() +export class TrialBillingStepService { + constructor( + private accountService: AccountService, + private apiService: ApiService, + private organizationBillingService: OrganizationBillingServiceAbstraction, + private taxClient: TaxClient, + ) {} + + private plans$ = from(this.apiService.getPlans()).pipe( + shareReplay({ bufferSize: 1, refCount: true }), + ); + + getPrices$ = (product: Product, tier: Tier) => + this.plans$.pipe( + map((plans) => { + switch (tier) { + case "families": { + const annually = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually); + return { + annually: annually!.PasswordManager.basePrice, + }; + } + case "teams": + case "enterprise": { + const annually = plans.data.find( + (plan) => + plan.type === + (tier === "teams" ? PlanType.TeamsAnnually : PlanType.EnterpriseAnnually), + ); + const monthly = plans.data.find( + (plan) => + plan.type === + (tier === "teams" ? PlanType.TeamsMonthly : PlanType.EnterpriseMonthly), + ); + switch (product) { + case "passwordManager": { + return { + annually: annually!.PasswordManager.seatPrice, + monthly: monthly!.PasswordManager.seatPrice, + }; + } + case "secretsManager": { + return { + annually: annually!.SecretsManager.seatPrice, + monthly: monthly!.SecretsManager.seatPrice, + }; + } + } + } + } + }), + ); + + getCosts = async ( + product: Product, + tier: Tier, + cadence: Cadence, + billingAddressControls: BillingAddressControls, + ): Promise<{ + tax: number; + total: number; + }> => { + const billingAddress = getBillingAddressFromControls(billingAddressControls); + return await this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + { + tier, + cadence, + passwordManager: { + seats: 1, + additionalStorage: 0, + sponsored: false, + }, + secretsManager: + product === "secretsManager" + ? { + seats: 1, + additionalServiceAccounts: 0, + standalone: true, + } + : undefined, + }, + billingAddress, + ); + }; + + startTrial = async ( + trial: Trial, + cadence: Cadence, + billingAddress: BillingAddressControls, + paymentMethod: TokenizedPaymentMethod, + ): Promise => { + const getPlanType = async (tier: Tier, cadence: Cadence) => { + const plans = await firstValueFrom(this.plans$); + switch (tier) { + case "families": + return plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!.type; + case "teams": + return plans.data.find( + (plan) => + plan.type === + (cadence === "annually" ? PlanType.TeamsAnnually : PlanType.TeamsMonthly), + )!.type; + case "enterprise": + return plans.data.find( + (plan) => + plan.type === + (cadence === "annually" ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly), + )!.type; + } + }; + + const legacyPaymentMethod: [string, PaymentMethodType] = [ + paymentMethod.token, + tokenizablePaymentMethodToLegacyEnum(paymentMethod.type), + ]; + const planType = await getPlanType(trial.tier, cadence); + + const request: SubscriptionInformation = { + organization: { + name: trial.organization.name, + billingEmail: trial.organization.email, + initiationPath: + trial.product === "passwordManager" + ? "Password Manager trial from marketing website" + : "Secrets Manager trial from marketing website", + }, + plan: + trial.product === "passwordManager" + ? { type: planType, passwordManagerSeats: 1 } + : { + type: planType, + passwordManagerSeats: 1, + subscribeToSecretsManager: true, + isFromSecretsManagerTrial: true, + secretsManagerSeats: 1, + }, + payment: { + paymentMethod: legacyPaymentMethod, + billing: { + country: billingAddress.country, + postalCode: billingAddress.postalCode, + taxId: billingAddress.taxId ?? undefined, + }, + skipTrial: trial.length === 0, + }, + }; + + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + return await this.organizationBillingService.purchaseSubscription(request, activeUserId); + }; +} diff --git a/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts b/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts index 06e1cce7f23..cd393f0dd5e 100644 --- a/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts +++ b/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts @@ -6,11 +6,11 @@ import { InputPasswordComponent } from "@bitwarden/auth/angular"; import { FormFieldModule } from "@bitwarden/components"; import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module"; -import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiation/trial-billing-step.component"; import { SharedModule } from "../../shared"; import { CompleteTrialInitiationComponent } from "./complete-trial-initiation/complete-trial-initiation.component"; import { ConfirmationDetailsComponent } from "./confirmation-details.component"; +import { TrialBillingStepComponent } from "./trial-billing-step/trial-billing-step.component"; import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module"; @NgModule({ diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html index 41ee1b4707e..09bd38c8517 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html @@ -29,15 +29,32 @@
-
+
-

{{ "bitwardenExtensionInstalled" | i18n }}

+

+ {{ + (state === SetupExtensionState.Success + ? "bitwardenExtensionInstalled" + : "openTheBitwardenExtension" + ) | i18n + }} +

-

{{ "openExtensionToAutofill" | i18n }}

+

+ {{ + (state === SetupExtensionState.Success + ? "openExtensionToAutofill" + : "bitwardenExtensionInstalledOpenExtension" + ) | i18n + }} +

diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts index 3be8251b1d7..f755c83832f 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts @@ -93,14 +93,9 @@ describe("SetupExtensionComponent", () => { }); describe("extensionInstalled$", () => { - it("redirects the user to the vault when the first emitted value is true", () => { - extensionInstalled$.next(true); - - expect(navigate).toHaveBeenCalledWith(["/vault"]); - }); - describe("success state", () => { beforeEach(() => { + update.mockClear(); // avoid initial redirect extensionInstalled$.next(false); diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts index 558f0eb06c9..a04c529004c 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts @@ -36,6 +36,7 @@ export const SetupExtensionState = { Loading: "loading", NeedsExtension: "needs-extension", Success: "success", + AlreadyInstalled: "already-installed", ManualOpen: "manual-open", } as const; @@ -99,9 +100,10 @@ export class SetupExtensionComponent implements OnInit, OnDestroy { this.webBrowserExtensionInteractionService.extensionInstalled$ .pipe(takeUntilDestroyed(this.destroyRef), startWith(null), pairwise()) .subscribe(([previousState, currentState]) => { - // Initial state transitioned to extension installed, redirect the user + // User landed on the page and the extension is already installed, show already installed state if (previousState === null && currentState) { - void this.router.navigate(["/vault"]); + void this.dismissExtensionPage(); + this.state = SetupExtensionState.AlreadyInstalled; } // Extension was not installed and now it is, show success state diff --git a/apps/web/src/app/vault/guards/setup-extension-redirect.guard.spec.ts b/apps/web/src/app/vault/guards/setup-extension-redirect.guard.spec.ts index 14f6763dbf9..7520451df34 100644 --- a/apps/web/src/app/vault/guards/setup-extension-redirect.guard.spec.ts +++ b/apps/web/src/app/vault/guards/setup-extension-redirect.guard.spec.ts @@ -82,13 +82,6 @@ describe("setupExtensionRedirectGuard", () => { expect(await setupExtensionGuard()).toBe(true); }); - it("returns `true` when the user has the extension installed", async () => { - state$.next(false); - extensionInstalled$.next(true); - - expect(await setupExtensionGuard()).toBe(true); - }); - it('redirects the user to "/setup-extension" when all criteria do not pass', async () => { state$.next(false); extensionInstalled$.next(false); diff --git a/apps/web/src/app/vault/guards/setup-extension-redirect.guard.ts b/apps/web/src/app/vault/guards/setup-extension-redirect.guard.ts index 5355ebf7232..0c9e31ae77f 100644 --- a/apps/web/src/app/vault/guards/setup-extension-redirect.guard.ts +++ b/apps/web/src/app/vault/guards/setup-extension-redirect.guard.ts @@ -11,8 +11,6 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; -import { WebBrowserInteractionService } from "../services/web-browser-interaction.service"; - export const SETUP_EXTENSION_DISMISSED = new UserKeyDefinition( SETUP_EXTENSION_DISMISSED_DISK, "setupExtensionDismissed", @@ -27,7 +25,6 @@ export const setupExtensionRedirectGuard: CanActivateFn = async () => { const accountService = inject(AccountService); const vaultProfileService = inject(VaultProfileService); const stateProvider = inject(StateProvider); - const webBrowserInteractionService = inject(WebBrowserInteractionService); const isMobile = Utils.isMobileBrowser; @@ -43,10 +40,6 @@ export const setupExtensionRedirectGuard: CanActivateFn = async () => { return router.createUrlTree(["/login"]); } - const hasExtensionInstalledPromise = firstValueFrom( - webBrowserInteractionService.extensionInstalled$, - ); - const dismissedExtensionPage = await firstValueFrom( stateProvider .getUser(currentAcct.id, SETUP_EXTENSION_DISMISSED) @@ -66,13 +59,6 @@ export const setupExtensionRedirectGuard: CanActivateFn = async () => { return true; } - // Checking for the extension is a more expensive operation, do it last to avoid unnecessary delays. - const hasExtensionInstalled = await hasExtensionInstalledPromise; - - if (hasExtensionInstalled) { - return true; - } - return router.createUrlTree(["/setup-extension"]); }; diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts index 011e7a3bce6..cd32eaf2858 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts @@ -5,8 +5,6 @@ import { filter, firstValueFrom, map, Observable, switchMap } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { UserId } from "@bitwarden/common/types/guid"; import { BannerModule } from "@bitwarden/components"; @@ -41,7 +39,6 @@ export class VaultBannersComponent implements OnInit { private router: Router, private accountService: AccountService, private messageListener: MessageListener, - private configService: ConfigService, ) { this.premiumBannerVisible$ = this.activeUserId$.pipe( filter((userId): userId is UserId => userId != null), @@ -75,16 +72,12 @@ export class VaultBannersComponent implements OnInit { } async navigateToPaymentMethod(organizationId: string): Promise { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; const navigationExtras = { state: { launchPaymentModalAutomatically: true }, }; await this.router.navigate( - ["organizations", organizationId, "billing", route], + ["organizations", organizationId, "billing", "payment-details"], navigationExtras, ); } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 1fc3047f2a3..278ae62aaa6 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -208,15 +208,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } applyOrganizationFilter = async (orgNode: TreeNode): Promise => { - if (!orgNode?.node.enabled) { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("disabledOrganizationFilterError"), - }); - await firstValueFrom( - this.organizationWarningsService.showInactiveSubscriptionDialog$(orgNode.node), - ); - } const filter = this.activeFilter; if (orgNode?.node.id === "AllVaults") { filter.resetOrganization(); @@ -433,7 +424,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { [ { id: "archive", - name: this.i18nService.t("archive"), + name: this.i18nService.t("archiveNoun"), type: "archive", icon: "bwi-archive", }, diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts index 63e6d391b42..aaa3a90aa98 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts @@ -140,7 +140,7 @@ export class VaultHeaderComponent { } if (this.filter.type === "archive") { - return this.i18nService.t("archive"); + return this.i18nService.t("archiveNoun"); } const activeOrganization = this.activeOrganization; diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index 958cf655d17..68219173deb 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -68,19 +68,20 @@ class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start" *ngIf="isEmpty && !performingInitialLoad" > - -
{{ "noItemsInArchive" | i18n }}
-

- {{ "archivedItemsDescription" | i18n }} + +

+ {{ (emptyState$ | async)?.title | i18n }} +
+

+ {{ (emptyState$ | async)?.description | i18n }}

-
{{ "noItemsInList" | i18n }}

{{ "paymentMethod" | i18n }}

- - + + diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts index 72ca0bc8391..0fa69c7a0e6 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts @@ -1,25 +1,24 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom, Subject, switchMap } from "rxjs"; import { first, takeUntil } from "rxjs/operators"; -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ProviderKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "@bitwarden/web-vault/app/billing/payment/components"; @Component({ selector: "provider-setup", @@ -27,16 +26,17 @@ import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/paymen standalone: false, }) export class SetupComponent implements OnInit, OnDestroy { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(ManageTaxInformationComponent) taxInformationComponent: ManageTaxInformationComponent; + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; loading = true; - providerId: string; - token: string; + providerId!: string; + token!: string; protected formGroup = this.formBuilder.group({ name: ["", Validators.required], billingEmail: ["", [Validators.required, Validators.email]], + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), }); private destroy$ = new Subject(); @@ -69,7 +69,7 @@ export class SetupComponent implements OnInit, OnDestroy { if (error) { this.toastService.showToast({ variant: "error", - title: null, + title: "", message: this.i18nService.t("emergencyInviteAcceptFailed"), timeout: 10000, }); @@ -95,6 +95,7 @@ export class SetupComponent implements OnInit, OnDestroy { replaceUrl: true, }); } + this.loading = false; } catch (error) { this.validationService.showError(error); @@ -115,10 +116,7 @@ export class SetupComponent implements OnInit, OnDestroy { try { this.formGroup.markAllAsTouched(); - const paymentValid = this.paymentComponent.validate(); - const taxInformationValid = this.taxInformationComponent.validate(); - - if (!paymentValid || !taxInformationValid || !this.formGroup.valid) { + if (this.formGroup.invalid) { return; } const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); @@ -126,29 +124,24 @@ export class SetupComponent implements OnInit, OnDestroy { const key = providerKey[0].encryptedString; const request = new ProviderSetupRequest(); - request.name = this.formGroup.value.name; - request.billingEmail = this.formGroup.value.billingEmail; + request.name = this.formGroup.value.name!; + request.billingEmail = this.formGroup.value.billingEmail!; request.token = this.token; - request.key = key; + request.key = key!; - request.taxInfo = new ExpandedTaxInfoUpdateRequest(); - const taxInformation = this.taxInformationComponent.getTaxInformation(); + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + return; + } - request.taxInfo.country = taxInformation.country; - request.taxInfo.postalCode = taxInformation.postalCode; - request.taxInfo.taxId = taxInformation.taxId; - request.taxInfo.line1 = taxInformation.line1; - request.taxInfo.line2 = taxInformation.line2; - request.taxInfo.city = taxInformation.city; - request.taxInfo.state = taxInformation.state; - - request.paymentSource = await this.paymentComponent.tokenize(); + request.paymentMethod = paymentMethod; + request.billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); const provider = await this.providerApiService.postProviderSetup(this.providerId, request); this.toastService.showToast({ variant: "success", - title: null, + title: "", message: this.i18nService.t("providerSetup"), }); @@ -156,20 +149,10 @@ export class SetupComponent implements OnInit, OnDestroy { await this.router.navigate(["/providers", provider.id]); } catch (e) { - if ( - this.paymentComponent.selected === PaymentMethodType.PayPal && - typeof e === "string" && - e === "No payment method is available." - ) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("clickPayWithPayPal"), - }); - } else { + if (e !== null && typeof e === "object" && "message" in e && typeof e.message === "string") { e.message = this.i18nService.translate(e.message) || e.message; - this.validationService.showError(e); } + this.validationService.showError(e); } }; } 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 ef8241b534c..6d2836ee0ba 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 @@ -30,8 +30,8 @@ {{ "ssoIdentifier" | i18n }} - {{ "ssoIdentifierHintPartOne" | i18n }} - {{ "claimedDomains" | i18n }} + {{ "ssoIdentifierHint" | i18n }} + {{ "claimedDomainsLearnMore" | i18n }} @@ -209,7 +209,14 @@ {{ "clientSecret" | i18n }} - + + @@ -488,7 +495,6 @@ formControlName="idpSingleSignOnServiceUrl" appInputStripSpaces /> - {{ "idpSingleSignOnServiceUrlRequired" | 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 9baeaabb33f..f68e35bf240 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 @@ -121,6 +121,8 @@ export class SsoComponent implements OnInit, OnDestroy { spMetadataUrl: string; spAcsUrl: string; + showClientSecret = false; + protected openIdForm = this.formBuilder.group>( { authority: new FormControl("", Validators.required), @@ -156,7 +158,7 @@ export class SsoComponent implements OnInit, OnDestroy { idpEntityId: new FormControl("", Validators.required), idpBindingType: new FormControl(Saml2BindingType.HttpRedirect), - idpSingleSignOnServiceUrl: new FormControl(), + idpSingleSignOnServiceUrl: new FormControl("", Validators.required), idpSingleLogoutServiceUrl: new FormControl(), idpX509PublicCert: new FormControl("", Validators.required), idpOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm), diff --git a/libs/angular/src/billing/components/invoices/invoices.component.html b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/invoices.component.html similarity index 100% rename from libs/angular/src/billing/components/invoices/invoices.component.html rename to bitwarden_license/bit-web/src/app/billing/providers/billing-history/invoices.component.html diff --git a/libs/angular/src/billing/components/invoices/invoices.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/invoices.component.ts similarity index 100% rename from libs/angular/src/billing/components/invoices/invoices.component.ts rename to bitwarden_license/bit-web/src/app/billing/providers/billing-history/invoices.component.ts diff --git a/libs/angular/src/billing/components/invoices/no-invoices.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/no-invoices.component.ts similarity index 100% rename from libs/angular/src/billing/components/invoices/no-invoices.component.ts rename to bitwarden_license/bit-web/src/app/billing/providers/billing-history/no-invoices.component.ts diff --git a/bitwarden_license/bit-web/src/app/billing/providers/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/index.ts index b1294bc8047..3cd83e68990 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/index.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/index.ts @@ -1,3 +1,5 @@ +export * from "./billing-history/invoices.component"; +export * from "./billing-history/no-invoices.component"; export * from "./billing-history/provider-billing-history.component"; export * from "./clients"; export * from "./guards/has-consolidated-billing.guard"; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts index d2ac2cede2f..5a070687de4 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts @@ -1,13 +1,10 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; +import { ActivatedRoute } from "@angular/router"; import { BehaviorSubject, combineLatest, - EMPTY, filter, firstValueFrom, - from, - map, merge, Observable, of, @@ -19,7 +16,6 @@ import { tap, withLatestFrom, } from "rxjs"; -import { catchError } from "rxjs/operators"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; @@ -49,13 +45,6 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { ProviderWarningsService } from "../warnings/services"; -class RedirectError { - constructor( - public path: string[], - public relativeTo: ActivatedRoute, - ) {} -} - type View = { activeUserId: UserId; provider: BitwardenSubscriber; @@ -92,18 +81,6 @@ export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy { ); private load$: Observable = this.provider$.pipe( - switchMap((provider) => - this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => { - if (!managePaymentDetailsOutsideCheckout) { - throw new RedirectError(["../subscription"], this.activatedRoute); - } - return provider; - }), - ), - ), mapProviderToSubscriber, switchMap(async (provider) => { const getTaxIdWarning = firstValueFrom( @@ -131,14 +108,6 @@ export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy { }; }), shareReplay({ bufferSize: 1, refCount: false }), - catchError((error: unknown) => { - if (error instanceof RedirectError) { - return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( - switchMap(() => EMPTY), - ); - } - throw error; - }), ); view$: Observable = merge( @@ -158,7 +127,6 @@ export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy { private messageListener: MessageListener, private providerService: ProviderService, private providerWarningsService: ProviderWarningsService, - private router: Router, private subscriberBillingClient: SubscriberBillingClient, ) {} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html index 0205d2838d1..05eda7e7ea4 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html @@ -62,51 +62,5 @@
- @if (!managePaymentDetailsOutsideCheckout) { - - -

- {{ "accountCredit" | i18n }} -

-

{{ subscription.accountCredit | currency: "$" }}

-

{{ "creditAppliedDesc" | i18n }}

-
- - -

{{ "paymentMethod" | i18n }}

-

- {{ "noPaymentMethod" | i18n }} -

- - - -

- - {{ subscription.paymentSource.description }} - - {{ "unverified" | i18n }} -

-
- -
- - -

{{ "taxInformation" | i18n }}

-

{{ "taxInformationDesc" | i18n }}

- -
- } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts index 83a23760d80..98aceb0f878 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts @@ -2,26 +2,14 @@ // @ts-strict-ignore import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { concatMap, lastValueFrom, Subject, takeUntil } from "rxjs"; +import { concatMap, Subject, takeUntil } from "rxjs"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; -import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; import { ProviderPlanResponse, ProviderSubscriptionResponse, } from "@bitwarden/common/billing/models/response/provider-subscription-response"; -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 { DialogService, ToastService } from "@bitwarden/components"; import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service"; -import { - AdjustPaymentDialogComponent, - AdjustPaymentDialogResultType, -} from "@bitwarden/web-vault/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component"; @Component({ selector: "app-provider-subscription", @@ -36,18 +24,11 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { protected loading: boolean; private destroy$ = new Subject(); protected totalCost: number; - protected managePaymentDetailsOutsideCheckout: boolean; - - protected readonly TaxInformation = TaxInformation; constructor( private billingApiService: BillingApiServiceAbstraction, - private i18nService: I18nService, private route: ActivatedRoute, private billingNotificationService: BillingNotificationService, - private dialogService: DialogService, - private toastService: ToastService, - private configService: ConfigService, ) {} async ngOnInit() { @@ -55,9 +36,6 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { .pipe( concatMap(async (params) => { this.providerId = params.providerId; - this.managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); await this.load(); this.firstLoaded = true; }), @@ -83,40 +61,6 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { } } - protected updatePaymentMethod = async (): Promise => { - const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, { - data: { - initialPaymentMethod: this.subscription.paymentSource?.type, - providerId: this.providerId, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); - - if (result === AdjustPaymentDialogResultType.Submitted) { - await this.load(); - } - }; - - protected updateTaxInformation = async (taxInformation: TaxInformation) => { - try { - const request = ExpandedTaxInfoUpdateRequest.From(taxInformation); - await this.billingApiService.updateProviderTaxInformation(this.providerId, request); - this.billingNotificationService.showSuccess(this.i18nService.t("updatedTaxInformation")); - } catch (error) { - this.billingNotificationService.handleError(error); - } - }; - - protected verifyBankAccount = async (request: VerifyBankAccountRequest): Promise => { - await this.billingApiService.verifyProviderBankAccount(this.providerId, request); - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("verifiedBankAccount"), - }); - }; - protected getFormattedCost( cost: number, seatMinimum: number, @@ -161,7 +105,7 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { } protected getBillingCadenceLabel(providerPlanResponse: ProviderPlanResponse): string { - if (providerPlanResponse == null || providerPlanResponse == undefined) { + if (providerPlanResponse == null) { return "month"; } @@ -174,27 +118,4 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { return "month"; } } - - protected get paymentSourceClasses() { - if (this.subscription.paymentSource == null) { - return []; - } - switch (this.subscription.paymentSource.type) { - case PaymentMethodType.Card: - return ["bwi-credit-card"]; - case PaymentMethodType.BankAccount: - case PaymentMethodType.Check: - return ["bwi-billing"]; - case PaymentMethodType.PayPal: - return ["bwi-paypal text-primary"]; - default: - return []; - } - } - - protected get updatePaymentSourceButtonText(): string { - const key = - this.subscription.paymentSource == null ? "addPaymentMethod" : "changePaymentMethod"; - return this.i18nService.t(key); - } } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/index.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts index d9ff3ec5619..e301c0462c3 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -23,8 +23,6 @@ import { import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -117,7 +115,6 @@ export class OverviewComponent implements OnInit, OnDestroy { private smOnboardingTasksService: SMOnboardingTasksService, private logService: LogService, private router: Router, - private configService: ConfigService, ) {} ngOnInit() { @@ -218,13 +215,12 @@ export class OverviewComponent implements OnInit, OnDestroy { } async navigateToPaymentMethod() { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + await this.router.navigate( + ["organizations", `${this.organizationId}`, "billing", "payment-details"], + { + state: { launchPaymentModalAutomatically: true }, + }, ); - const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; - await this.router.navigate(["organizations", `${this.organizationId}`, "billing", route], { - state: { launchPaymentModalAutomatically: true }, - }); } ngOnDestroy(): void { diff --git a/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.html b/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.html deleted file mode 100644 index c9c0c296ada..00000000000 --- a/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.html +++ /dev/null @@ -1,55 +0,0 @@ -
- - -

{{ "creditDelayed" | i18n }}

-
- - - {{ "payPal" | i18n }} - - - {{ "bitcoin" | i18n }} - - -
-
- - {{ "amount" | i18n }} - - $USD - -
-
- - - - -
-
-
- - - - - - - - - - - - - - - -
diff --git a/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.ts b/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.ts deleted file mode 100644 index 3dc56c55b0c..00000000000 --- a/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.ts +++ /dev/null @@ -1,167 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { firstValueFrom, map } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; -import { AccountService, AccountInfo } from "@bitwarden/common/auth/abstractions/account.service"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { UserId } from "@bitwarden/common/types/guid"; -import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; - -export type AddAccountCreditDialogParams = { - organizationId?: string; - providerId?: string; -}; - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum AddAccountCreditDialogResultType { - Closed = "closed", - Submitted = "submitted", -} - -export const openAddAccountCreditDialog = ( - dialogService: DialogService, - dialogConfig: DialogConfig, -) => - dialogService.open( - AddAccountCreditDialogComponent, - dialogConfig, - ); - -type PayPalConfig = { - businessId?: string; - buttonAction?: string; - returnUrl?: string; - customField?: string; - subject?: string; -}; - -@Component({ - templateUrl: "./add-account-credit-dialog.component.html", - standalone: false, -}) -export class AddAccountCreditDialogComponent implements OnInit { - @ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm: ElementRef; - protected formGroup = new FormGroup({ - paymentMethod: new FormControl(PaymentMethodType.PayPal), - creditAmount: new FormControl(null, [Validators.required, Validators.min(0.01)]), - }); - protected payPalConfig: PayPalConfig; - protected ResultType = AddAccountCreditDialogResultType; - - private organization?: Organization; - private provider?: Provider; - private user?: { id: UserId } & AccountInfo; - - constructor( - private accountService: AccountService, - private apiService: ApiService, - private configService: ConfigService, - @Inject(DIALOG_DATA) private dialogParams: AddAccountCreditDialogParams, - private dialogRef: DialogRef, - private organizationService: OrganizationService, - private platformUtilsService: PlatformUtilsService, - private providerService: ProviderService, - ) { - this.payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig; - } - - protected readonly paymentMethodType = PaymentMethodType; - - submit = async () => { - this.formGroup.markAllAsTouched(); - - if (this.formGroup.invalid) { - return; - } - - if (this.formGroup.value.paymentMethod === PaymentMethodType.PayPal) { - this.payPalForm.nativeElement.submit(); - return; - } - - if (this.formGroup.value.paymentMethod === PaymentMethodType.BitPay) { - const request = this.getBitPayInvoiceRequest(); - const bitPayUrl = await this.apiService.postBitPayInvoice(request); - this.platformUtilsService.launchUri(bitPayUrl); - return; - } - - this.dialogRef.close(AddAccountCreditDialogResultType.Submitted); - }; - - async ngOnInit(): Promise { - let payPalCustomField: string; - - if (this.dialogParams.organizationId) { - this.formGroup.patchValue({ - creditAmount: 20.0, - }); - this.user = await firstValueFrom(this.accountService.activeAccount$); - this.organization = await firstValueFrom( - this.organizationService - .organizations$(this.user.id) - .pipe( - map((organizations) => - organizations.find((org) => org.id === this.dialogParams.organizationId), - ), - ), - ); - payPalCustomField = "organization_id:" + this.organization.id; - this.payPalConfig.subject = this.organization.name; - } else if (this.dialogParams.providerId) { - this.formGroup.patchValue({ - creditAmount: 20.0, - }); - this.provider = await firstValueFrom( - this.providerService.get$(this.dialogParams.providerId, this.user.id), - ); - payPalCustomField = "provider_id:" + this.provider.id; - this.payPalConfig.subject = this.provider.name; - } else { - this.formGroup.patchValue({ - creditAmount: 10.0, - }); - payPalCustomField = "user_id:" + this.user.id; - this.payPalConfig.subject = this.user.email; - } - - const region = await firstValueFrom(this.configService.cloudRegion$); - - payPalCustomField += ",account_credit:1"; - payPalCustomField += `,region:${region}`; - - this.payPalConfig.customField = payPalCustomField; - this.payPalConfig.returnUrl = window.location.href; - } - - getBitPayInvoiceRequest(): BitPayInvoiceRequest { - const request = new BitPayInvoiceRequest(); - if (this.organization) { - request.name = this.organization.name; - request.organizationId = this.organization.id; - } else if (this.provider) { - request.name = this.provider.name; - request.providerId = this.provider.id; - } else { - request.email = this.user.email; - request.userId = this.user.id; - } - - request.credit = true; - request.amount = this.formGroup.value.creditAmount; - request.returnUrl = window.location.href; - - return request; - } -} diff --git a/libs/angular/src/billing/components/index.ts b/libs/angular/src/billing/components/index.ts index dacb5b265bd..34e1d27c1ed 100644 --- a/libs/angular/src/billing/components/index.ts +++ b/libs/angular/src/billing/components/index.ts @@ -1,5 +1 @@ -export * from "./add-account-credit-dialog/add-account-credit-dialog.component"; -export * from "./invoices/invoices.component"; -export * from "./invoices/no-invoices.component"; -export * from "./manage-tax-information/manage-tax-information.component"; export * from "./premium.component"; diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html deleted file mode 100644 index 391765251b0..00000000000 --- a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html +++ /dev/null @@ -1,90 +0,0 @@ -
-
-
- - {{ "country" | i18n }} - - - - -
-
- - {{ "zipPostalCode" | i18n }} - - -
- -
- - {{ "address1" | i18n }} - - -
-
- - {{ "address2" | i18n }} - - -
-
- - {{ "cityTown" | i18n }} - - -
-
- - {{ "stateProvince" | i18n }} - - -
-
- - {{ "taxIdNumber" | i18n }} - - -
-
-
- -
-
-
diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.spec.ts b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.spec.ts deleted file mode 100644 index c662e20b275..00000000000 --- a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.spec.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { SimpleChange } from "@angular/core"; -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ReactiveFormsModule } from "@angular/forms"; -import { mock, MockProxy } from "jest-mock-extended"; - -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SelectModule, FormFieldModule, BitSubmitDirective } from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; - -import { ManageTaxInformationComponent } from "./manage-tax-information.component"; - -describe("ManageTaxInformationComponent", () => { - let sut: ManageTaxInformationComponent; - let fixture: ComponentFixture; - let mockTaxService: MockProxy; - - beforeEach(async () => { - mockTaxService = mock(); - await TestBed.configureTestingModule({ - declarations: [ManageTaxInformationComponent], - providers: [ - { provide: TaxServiceAbstraction, useValue: mockTaxService }, - { provide: I18nService, useValue: { t: (key: string) => key } }, - ], - imports: [ - CommonModule, - ReactiveFormsModule, - SelectModule, - FormFieldModule, - BitSubmitDirective, - I18nPipe, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ManageTaxInformationComponent); - sut = fixture.componentInstance; - fixture.autoDetectChanges(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it("creates successfully", () => { - expect(sut).toBeTruthy(); - }); - - it("should initialize with all values empty in startWith", async () => { - // Arrange - sut.startWith = { - country: "", - postalCode: "", - taxId: "", - line1: "", - line2: "", - city: "", - state: "", - }; - - // Act - fixture.detectChanges(); - - // Assert - const startWithValue = sut.startWith; - expect(startWithValue.line1).toHaveLength(0); - expect(startWithValue.line2).toHaveLength(0); - expect(startWithValue.city).toHaveLength(0); - expect(startWithValue.state).toHaveLength(0); - expect(startWithValue.postalCode).toHaveLength(0); - expect(startWithValue.country).toHaveLength(0); - expect(startWithValue.taxId).toHaveLength(0); - }); - - it("should update the tax information protected state when form is updated", async () => { - // Arrange - const line1Value = "123 Street"; - const line2Value = "Apt. 5"; - const cityValue = "New York"; - const stateValue = "NY"; - const countryValue = "USA"; - const postalCodeValue = "123 Street"; - - sut.startWith = { - country: countryValue, - postalCode: "", - taxId: "", - line1: "", - line2: "", - city: "", - state: "", - }; - sut.showTaxIdField = false; - mockTaxService.isCountrySupported.mockResolvedValue(true); - - // Act - await sut.ngOnInit(); - fixture.detectChanges(); - - const line1: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='line1']", - ); - const line2: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='line2']", - ); - const city: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='city']", - ); - const state: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='state']", - ); - const postalCode: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='postalCode']", - ); - - line1.value = line1Value; - line2.value = line2Value; - city.value = cityValue; - state.value = stateValue; - postalCode.value = postalCodeValue; - - line1.dispatchEvent(new Event("input")); - line2.dispatchEvent(new Event("input")); - city.dispatchEvent(new Event("input")); - state.dispatchEvent(new Event("input")); - postalCode.dispatchEvent(new Event("input")); - await fixture.whenStable(); - - // Assert - - // Assert that the internal tax information reflects the form - const taxInformation = sut.getTaxInformation(); - expect(taxInformation.line1).toBe(line1Value); - expect(taxInformation.line2).toBe(line2Value); - expect(taxInformation.city).toBe(cityValue); - expect(taxInformation.state).toBe(stateValue); - expect(taxInformation.postalCode).toBe(postalCodeValue); - expect(taxInformation.country).toBe(countryValue); - expect(taxInformation.taxId).toHaveLength(0); - - expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue); - expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(2); - }); - - it("should not show address fields except postal code if country is not supported for taxes", async () => { - // Arrange - const countryValue = "UNKNOWN"; - sut.startWith = { - country: countryValue, - postalCode: "", - taxId: "", - line1: "", - line2: "", - city: "", - state: "", - }; - sut.showTaxIdField = false; - mockTaxService.isCountrySupported.mockResolvedValue(false); - - // Act - await sut.ngOnInit(); - fixture.detectChanges(); - - const line1: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='line1']", - ); - const line2: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='line2']", - ); - const city: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='city']", - ); - const state: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='state']", - ); - const postalCode: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='postalCode']", - ); - - // Assert - expect(line1).toBeNull(); - expect(line2).toBeNull(); - expect(city).toBeNull(); - expect(state).toBeNull(); - //Should be visible - expect(postalCode).toBeTruthy(); - - expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue); - expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1); - }); - - it("should not show the tax id field if showTaxIdField is set to false", async () => { - // Arrange - const countryValue = "USA"; - sut.startWith = { - country: countryValue, - postalCode: "", - taxId: "", - line1: "", - line2: "", - city: "", - state: "", - }; - - sut.showTaxIdField = false; - mockTaxService.isCountrySupported.mockResolvedValue(true); - - // Act - await sut.ngOnInit(); - fixture.detectChanges(); - - // Assert - const taxId: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='taxId']", - ); - expect(taxId).toBeNull(); - - expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue); - expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1); - }); - - it("should clear the tax id field if showTaxIdField is set to false after being true", async () => { - // Arrange - const countryValue = "USA"; - const taxIdValue = "A12345678"; - - sut.startWith = { - country: countryValue, - postalCode: "", - taxId: taxIdValue, - line1: "", - line2: "", - city: "", - state: "", - }; - sut.showTaxIdField = true; - - mockTaxService.isCountrySupported.mockResolvedValue(true); - await sut.ngOnInit(); - fixture.detectChanges(); - const initialTaxIdValue = fixture.nativeElement.querySelector( - "input[formControlName='taxId']", - ).value; - - // Act - sut.showTaxIdField = false; - sut.ngOnChanges({ showTaxIdField: new SimpleChange(true, false, false) }); - fixture.detectChanges(); - - // Assert - const taxId = fixture.nativeElement.querySelector("input[formControlName='taxId']"); - expect(taxId).toBeNull(); - - const taxInformation = sut.getTaxInformation(); - expect(taxInformation.taxId).toBeNull(); - expect(initialTaxIdValue).toEqual(taxIdValue); - - expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue); - expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1); - }); -}); diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts deleted file mode 100644 index 0b87f3f931d..00000000000 --- a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts +++ /dev/null @@ -1,166 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { - Component, - EventEmitter, - Input, - OnChanges, - OnDestroy, - OnInit, - Output, - SimpleChanges, -} from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { Subject, takeUntil } from "rxjs"; -import { debounceTime } from "rxjs/operators"; - -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { CountryListItem, TaxInformation } from "@bitwarden/common/billing/models/domain"; - -@Component({ - selector: "app-manage-tax-information", - templateUrl: "./manage-tax-information.component.html", - standalone: false, -}) -export class ManageTaxInformationComponent implements OnInit, OnDestroy, OnChanges { - @Input() startWith: TaxInformation; - @Input() onSubmit?: (taxInformation: TaxInformation) => Promise; - @Input() showTaxIdField: boolean = true; - - /** - * Emits when the tax information has changed. - */ - @Output() taxInformationChanged = new EventEmitter(); - - /** - * Emits when the tax information has been updated. - */ - @Output() taxInformationUpdated = new EventEmitter(); - - private taxInformation: TaxInformation; - - protected formGroup = this.formBuilder.group({ - country: ["", Validators.required], - postalCode: ["", Validators.required], - taxId: "", - line1: "", - line2: "", - city: "", - state: "", - }); - - protected isTaxSupported: boolean; - - private destroy$ = new Subject(); - - protected readonly countries: CountryListItem[] = this.taxService.getCountries(); - - constructor( - private formBuilder: FormBuilder, - private taxService: TaxServiceAbstraction, - ) {} - - getTaxInformation(): TaxInformation { - return this.taxInformation; - } - - submit = async () => { - this.markAllAsTouched(); - if (this.formGroup.invalid) { - return; - } - await this.onSubmit?.(this.taxInformation); - this.taxInformationUpdated.emit(); - }; - - validate(): boolean { - this.markAllAsTouched(); - return this.formGroup.valid; - } - - markAllAsTouched() { - this.formGroup.markAllAsTouched(); - } - - async ngOnInit() { - this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => { - this.taxInformation = { - country: values.country, - postalCode: values.postalCode, - taxId: values.taxId, - line1: values.line1, - line2: values.line2, - city: values.city, - state: values.state, - }; - }); - - if (this.startWith) { - this.formGroup.controls.country.setValue(this.startWith.country); - this.formGroup.controls.postalCode.setValue(this.startWith.postalCode); - - this.isTaxSupported = - this.startWith && this.startWith.country - ? await this.taxService.isCountrySupported(this.startWith.country) - : false; - - if (this.isTaxSupported) { - this.formGroup.controls.taxId.setValue(this.startWith.taxId); - this.formGroup.controls.line1.setValue(this.startWith.line1); - this.formGroup.controls.line2.setValue(this.startWith.line2); - this.formGroup.controls.city.setValue(this.startWith.city); - this.formGroup.controls.state.setValue(this.startWith.state); - } - } - - this.formGroup.controls.country.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe((country: string) => { - this.taxService - .isCountrySupported(country) - .then((isSupported) => (this.isTaxSupported = isSupported)) - .catch(() => (this.isTaxSupported = false)) - .finally(() => { - if (!this.isTaxSupported) { - this.formGroup.controls.taxId.setValue(null); - this.formGroup.controls.line1.setValue(null); - this.formGroup.controls.line2.setValue(null); - this.formGroup.controls.city.setValue(null); - this.formGroup.controls.state.setValue(null); - } - if (this.taxInformationChanged) { - this.taxInformationChanged.emit(this.taxInformation); - } - }); - }); - - this.formGroup.controls.postalCode.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe(() => { - if (this.taxInformationChanged) { - this.taxInformationChanged.emit(this.taxInformation); - } - }); - - this.formGroup.controls.taxId.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe(() => { - if (this.taxInformationChanged) { - this.taxInformationChanged.emit(this.taxInformation); - } - }); - } - - ngOnChanges(changes: SimpleChanges): void { - // Clear the value of the tax-id if states have been changed in the parent component - const showTaxIdField = changes["showTaxIdField"]; - if (showTaxIdField && !showTaxIdField.currentValue) { - this.formGroup.controls.taxId.setValue(null); - } - } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } -} diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index c0bf1425d47..446530a1111 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -2,12 +2,6 @@ import { CommonModule, DatePipe } from "@angular/common"; import { NgModule } from "@angular/core"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; -import { - AddAccountCreditDialogComponent, - InvoicesComponent, - NoInvoicesComponent, - ManageTaxInformationComponent, -} from "@bitwarden/angular/billing/components"; import { AsyncActionsModule, AutofocusDirective, @@ -112,10 +106,6 @@ import { IconComponent } from "./vault/components/icon.component"; UserTypePipe, IfFeatureDirective, FingerprintPipe, - AddAccountCreditDialogComponent, - InvoicesComponent, - NoInvoicesComponent, - ManageTaxInformationComponent, TwoFactorIconComponent, ], exports: [ @@ -146,10 +136,6 @@ import { IconComponent } from "./vault/components/icon.component"; UserTypePipe, IfFeatureDirective, FingerprintPipe, - AddAccountCreditDialogComponent, - InvoicesComponent, - NoInvoicesComponent, - ManageTaxInformationComponent, TwoFactorIconComponent, TextDragDirective, ], diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 03d756ee11c..3304c54a86f 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -20,11 +20,13 @@ import { import { DefaultLoginComponentService, DefaultLoginDecryptionOptionsService, + DefaultNewDeviceVerificationComponentService, DefaultRegistrationFinishService, DefaultTwoFactorAuthComponentService, DefaultTwoFactorAuthWebAuthnComponentService, LoginComponentService, LoginDecryptionOptionsService, + NewDeviceVerificationComponentService, RegistrationFinishService as RegistrationFinishServiceAbstraction, TwoFactorAuthComponentService, TwoFactorAuthWebAuthnComponentService, @@ -144,14 +146,12 @@ import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/a import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service"; import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; -import { TaxService } from "@bitwarden/common/billing/services/tax.service"; import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service"; import { DefaultKeyGenerationService, @@ -1398,11 +1398,6 @@ const safeProviders: SafeProvider[] = [ useClass: BillingApiService, deps: [ApiServiceAbstraction], }), - safeProvider({ - provide: TaxServiceAbstraction, - useClass: TaxService, - deps: [ApiServiceAbstraction], - }), safeProvider({ provide: BillingAccountProfileStateService, useClass: DefaultBillingAccountProfileStateService, @@ -1653,6 +1648,11 @@ const safeProviders: SafeProvider[] = [ ConfigService, ], }), + safeProvider({ + provide: NewDeviceVerificationComponentService, + useClass: DefaultNewDeviceVerificationComponentService, + deps: [], + }), ]; @NgModule({ diff --git a/libs/assets/src/svg/svgs/favorites.icon.ts b/libs/assets/src/svg/svgs/favorites.icon.ts new file mode 100644 index 00000000000..4725d0b0a7c --- /dev/null +++ b/libs/assets/src/svg/svgs/favorites.icon.ts @@ -0,0 +1,26 @@ +import { svgIcon } from "../icon-service"; + +export const FavoritesIcon = svgIcon` + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/libs/assets/src/svg/svgs/index.ts b/libs/assets/src/svg/svgs/index.ts index ab4f2c23f13..40a91f78d4d 100644 --- a/libs/assets/src/svg/svgs/index.ts +++ b/libs/assets/src/svg/svgs/index.ts @@ -12,6 +12,7 @@ export * from "./deactivated-org"; export * from "./devices.icon"; export * from "./domain.icon"; export * from "./empty-trash"; +export * from "./favorites.icon"; export * from "./gear"; export * from "./generator"; export * from "./item-types"; diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index fdd06509511..454a9091c25 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -59,6 +59,8 @@ export * from "./two-factor-auth"; // device verification export * from "./new-device-verification/new-device-verification.component"; +export * from "./new-device-verification/new-device-verification-component.service"; +export * from "./new-device-verification/default-new-device-verification-component.service"; // validators export * from "./validators/compare-inputs.validator"; diff --git a/libs/auth/src/angular/new-device-verification/default-new-device-verification-component.service.spec.ts b/libs/auth/src/angular/new-device-verification/default-new-device-verification-component.service.spec.ts new file mode 100644 index 00000000000..a2ea26268ea --- /dev/null +++ b/libs/auth/src/angular/new-device-verification/default-new-device-verification-component.service.spec.ts @@ -0,0 +1,21 @@ +import { DefaultNewDeviceVerificationComponentService } from "./default-new-device-verification-component.service"; + +describe("DefaultNewDeviceVerificationComponentService", () => { + let sut: DefaultNewDeviceVerificationComponentService; + + beforeEach(() => { + sut = new DefaultNewDeviceVerificationComponentService(); + }); + + it("should instantiate the service", () => { + expect(sut).not.toBeFalsy(); + }); + + describe("showBackButton()", () => { + it("should return true", () => { + const result = sut.showBackButton(); + + expect(result).toBe(true); + }); + }); +}); diff --git a/libs/auth/src/angular/new-device-verification/default-new-device-verification-component.service.ts b/libs/auth/src/angular/new-device-verification/default-new-device-verification-component.service.ts new file mode 100644 index 00000000000..88ea652bc4b --- /dev/null +++ b/libs/auth/src/angular/new-device-verification/default-new-device-verification-component.service.ts @@ -0,0 +1,9 @@ +import { NewDeviceVerificationComponentService } from "./new-device-verification-component.service"; + +export class DefaultNewDeviceVerificationComponentService + implements NewDeviceVerificationComponentService +{ + showBackButton() { + return true; + } +} diff --git a/libs/auth/src/angular/new-device-verification/new-device-verification-component.service.ts b/libs/auth/src/angular/new-device-verification/new-device-verification-component.service.ts new file mode 100644 index 00000000000..c34fc40ef00 --- /dev/null +++ b/libs/auth/src/angular/new-device-verification/new-device-verification-component.service.ts @@ -0,0 +1,8 @@ +export abstract class NewDeviceVerificationComponentService { + /** + * States whether component should show a back button. Can be overridden by client-specific component services. + * - Default = `true` + * - Extension = `false` (because Extension shows a back button in the header instead) + */ + abstract showBackButton: () => boolean; +} diff --git a/libs/auth/src/angular/new-device-verification/new-device-verification.component.html b/libs/auth/src/angular/new-device-verification/new-device-verification.component.html index e731f3afcb6..814c48db0fc 100644 --- a/libs/auth/src/angular/new-device-verification/new-device-verification.component.html +++ b/libs/auth/src/angular/new-device-verification/new-device-verification.component.html @@ -22,7 +22,7 @@ {{ "resendCode" | i18n }} -
+
+ + @if (showBackButton) { +
{{ "or" | i18n }}
+ + + }
diff --git a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts index 6362b901fc8..2211b3390a7 100644 --- a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts +++ b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts @@ -1,4 +1,4 @@ -import { CommonModule } from "@angular/common"; +import { CommonModule, Location } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import { Router } from "@angular/router"; @@ -11,7 +11,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. @@ -26,6 +25,8 @@ import { import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service"; +import { NewDeviceVerificationComponentService } from "./new-device-verification-component.service"; + /** * Component for verifying a new device via a one-time password (OTP). */ @@ -57,6 +58,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy { protected disableRequestOTP = false; private destroy$ = new Subject(); protected authenticationSessionTimeoutRoute = "/authentication-timeout"; + protected showBackButton = true; constructor( private router: Router, @@ -66,12 +68,15 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy { private logService: LogService, private i18nService: I18nService, private loginSuccessHandlerService: LoginSuccessHandlerService, - private configService: ConfigService, private accountService: AccountService, private masterPasswordService: MasterPasswordServiceAbstraction, + private newDeviceVerificationComponentService: NewDeviceVerificationComponentService, + private location: Location, ) {} async ngOnInit() { + this.showBackButton = this.newDeviceVerificationComponentService.showBackButton(); + // Redirect to timeout route if session expires this.loginStrategyService.authenticationSessionTimeout$ .pipe(takeUntil(this.destroy$)) @@ -179,4 +184,8 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy { codeControl.markAsTouched(); } }; + + protected goBack() { + this.location.back(); + } } diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index d746342d728..28ab0613e14 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -77,14 +77,10 @@ import { } from "../auth/models/response/two-factor-web-authn.response"; import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response"; import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request"; -import { PaymentRequest } from "../billing/models/request/payment.request"; -import { TaxInfoUpdateRequest } from "../billing/models/request/tax-info-update.request"; import { BillingHistoryResponse } from "../billing/models/response/billing-history.response"; -import { BillingPaymentResponse } from "../billing/models/response/billing-payment.response"; import { PaymentResponse } from "../billing/models/response/payment.response"; import { PlanResponse } from "../billing/models/response/plan.response"; import { SubscriptionResponse } from "../billing/models/response/subscription.response"; -import { TaxInfoResponse } from "../billing/models/response/tax-info.response"; import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request"; import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request"; import { DeleteRecoverRequest } from "../models/request/delete-recover.request"; @@ -171,10 +167,8 @@ export abstract class ApiService { abstract getProfile(): Promise; abstract getUserSubscription(): Promise; - abstract getTaxInfo(): Promise; abstract putProfile(request: UpdateProfileRequest): Promise; abstract putAvatar(request: UpdateAvatarRequest): Promise; - abstract putTaxInfo(request: TaxInfoUpdateRequest): Promise; abstract postPrelogin(request: PreloginRequest): Promise; abstract postEmailToken(request: EmailTokenRequest): Promise; abstract postEmail(request: EmailRequest): Promise; @@ -185,7 +179,6 @@ export abstract class ApiService { abstract postPremium(data: FormData): Promise; abstract postReinstatePremium(): Promise; abstract postAccountStorage(request: StorageRequest): Promise; - abstract postAccountPayment(request: PaymentRequest): Promise; abstract postAccountLicense(data: FormData): Promise; abstract postAccountKeys(request: KeysRequest): Promise; abstract postAccountVerifyEmail(): Promise; @@ -209,7 +202,6 @@ export abstract class ApiService { abstract getLastAuthRequest(): Promise; abstract getUserBillingHistory(): Promise; - abstract getUserBillingPayment(): Promise; abstract getCipher(id: string): Promise; abstract getFullCipherDetails(id: string): Promise; diff --git a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts index 10626d6758f..6c91c2ea0cf 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts @@ -3,21 +3,17 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; -import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; -import { PaymentRequest } from "../../../billing/models/request/payment.request"; import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request"; import { BillingHistoryResponse } from "../../../billing/models/response/billing-history.response"; import { BillingResponse } from "../../../billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response"; import { PaymentResponse } from "../../../billing/models/response/payment.response"; -import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response"; import { ImportDirectoryRequest } from "../../../models/request/import-directory.request"; import { SeatRequest } from "../../../models/request/seat.request"; import { StorageRequest } from "../../../models/request/storage.request"; -import { VerifyBankRequest } from "../../../models/request/verify-bank.request"; import { ListResponse } from "../../../models/response/list.response"; import { OrganizationApiKeyType } from "../../enums"; import { OrganizationCollectionManagementUpdateRequest } from "../../models/request/organization-collection-management-update.request"; @@ -45,7 +41,6 @@ export abstract class OrganizationApiServiceAbstraction { ): Promise; abstract createLicense(data: FormData): Promise; abstract save(id: string, request: OrganizationUpdateRequest): Promise; - abstract updatePayment(id: string, request: PaymentRequest): Promise; abstract upgrade(id: string, request: OrganizationUpgradeRequest): Promise; abstract updatePasswordManagerSeats( id: string, @@ -57,7 +52,6 @@ export abstract class OrganizationApiServiceAbstraction { ): Promise; abstract updateSeats(id: string, request: SeatRequest): Promise; abstract updateStorage(id: string, request: StorageRequest): Promise; - abstract verifyBank(id: string, request: VerifyBankRequest): Promise; abstract reinstate(id: string): Promise; abstract leave(id: string): Promise; abstract delete(id: string, request: SecretVerificationRequest): Promise; @@ -76,8 +70,6 @@ export abstract class OrganizationApiServiceAbstraction { organizationApiKeyType?: OrganizationApiKeyType, ): Promise>; abstract rotateApiKey(id: string, request: OrganizationApiKeyRequest): Promise; - abstract getTaxInfo(id: string): Promise; - abstract updateTaxInfo(id: string, request: ExpandedTaxInfoUpdateRequest): Promise; abstract getKeys(id: string): Promise; abstract updateKeys( id: string, diff --git a/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts b/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts index 5c9ea5526a0..001bba11cf4 100644 --- a/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts +++ b/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts @@ -1,7 +1,19 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { ExpandedTaxInfoUpdateRequest } from "../../../../billing/models/request/expanded-tax-info-update.request"; -import { TokenizedPaymentSourceRequest } from "../../../../billing/models/request/tokenized-payment-source.request"; +interface TokenizedPaymentMethod { + type: "bankAccount" | "card" | "payPal"; + token: string; +} + +interface BillingAddress { + country: string; + postalCode: string; + line1: string | null; + line2: string | null; + city: string | null; + state: string | null; + taxId: { code: string; value: string } | null; +} export class ProviderSetupRequest { name: string; @@ -9,6 +21,6 @@ export class ProviderSetupRequest { billingEmail: string; token: string; key: string; - taxInfo: ExpandedTaxInfoUpdateRequest; - paymentSource?: TokenizedPaymentSourceRequest; + paymentMethod: TokenizedPaymentMethod; + billingAddress: BillingAddress; } diff --git a/libs/common/src/admin-console/services/organization/organization-api.service.ts b/libs/common/src/admin-console/services/organization/organization-api.service.ts index 598bb2a29db..6a7b71389bb 100644 --- a/libs/common/src/admin-console/services/organization/organization-api.service.ts +++ b/libs/common/src/admin-console/services/organization/organization-api.service.ts @@ -7,21 +7,17 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; -import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; -import { PaymentRequest } from "../../../billing/models/request/payment.request"; import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request"; import { BillingHistoryResponse } from "../../../billing/models/response/billing-history.response"; import { BillingResponse } from "../../../billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response"; import { PaymentResponse } from "../../../billing/models/response/payment.response"; -import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response"; import { ImportDirectoryRequest } from "../../../models/request/import-directory.request"; import { SeatRequest } from "../../../models/request/seat.request"; import { StorageRequest } from "../../../models/request/storage.request"; -import { VerifyBankRequest } from "../../../models/request/verify-bank.request"; import { ListResponse } from "../../../models/response/list.response"; import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction"; import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction"; @@ -143,10 +139,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return data; } - async updatePayment(id: string, request: PaymentRequest): Promise { - return this.apiService.send("POST", "/organizations/" + id + "/payment", request, true, false); - } - async upgrade(id: string, request: OrganizationUpgradeRequest): Promise { const r = await this.apiService.send( "POST", @@ -208,16 +200,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return new PaymentResponse(r); } - async verifyBank(id: string, request: VerifyBankRequest): Promise { - await this.apiService.send( - "POST", - "/organizations/" + id + "/verify-bank", - request, - true, - false, - ); - } - async reinstate(id: string): Promise { return this.apiService.send("POST", "/organizations/" + id + "/reinstate", null, true, false); } @@ -299,16 +281,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return new ApiKeyResponse(r); } - async getTaxInfo(id: string): Promise { - const r = await this.apiService.send("GET", "/organizations/" + id + "/tax", null, true, true); - return new TaxInfoResponse(r); - } - - async updateTaxInfo(id: string, request: ExpandedTaxInfoUpdateRequest): Promise { - // Can't broadcast anything because the response doesn't have content - return this.apiService.send("PUT", "/organizations/" + id + "/tax", request, true, false); - } - async getKeys(id: string): Promise { const r = await this.apiService.send("GET", "/organizations/" + id + "/keys", null, true, true); return new OrganizationKeysResponse(r); diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts index 2f3fe9125db..b5695e2e8a0 100644 --- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts @@ -1,19 +1,12 @@ -import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response"; - import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response"; import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response"; import { PlanResponse } from "../../billing/models/response/plan.response"; import { ListResponse } from "../../models/response/list.response"; -import { PaymentMethodType } from "../enums"; import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request"; -import { ExpandedTaxInfoUpdateRequest } from "../models/request/expanded-tax-info-update.request"; import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request"; -import { UpdatePaymentMethodRequest } from "../models/request/update-payment-method.request"; -import { VerifyBankAccountRequest } from "../models/request/verify-bank-account.request"; import { InvoicesResponse } from "../models/response/invoices.response"; -import { PaymentMethodResponse } from "../models/response/payment-method.response"; import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response"; export abstract class BillingApiServiceAbstraction { @@ -29,14 +22,10 @@ export abstract class BillingApiServiceAbstraction { request: CreateClientOrganizationRequest, ): Promise; - abstract createSetupIntent(paymentMethodType: PaymentMethodType): Promise; - abstract getOrganizationBillingMetadata( organizationId: string, ): Promise; - abstract getOrganizationPaymentMethod(organizationId: string): Promise; - abstract getPlans(): Promise>; abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise; @@ -49,44 +38,12 @@ export abstract class BillingApiServiceAbstraction { abstract getProviderSubscription(providerId: string): Promise; - abstract getProviderTaxInformation(providerId: string): Promise; - - abstract updateOrganizationPaymentMethod( - organizationId: string, - request: UpdatePaymentMethodRequest, - ): Promise; - - abstract updateOrganizationTaxInformation( - organizationId: string, - request: ExpandedTaxInfoUpdateRequest, - ): Promise; - abstract updateProviderClientOrganization( providerId: string, organizationId: string, request: UpdateClientOrganizationRequest, ): Promise; - abstract updateProviderPaymentMethod( - providerId: string, - request: UpdatePaymentMethodRequest, - ): Promise; - - abstract updateProviderTaxInformation( - providerId: string, - request: ExpandedTaxInfoUpdateRequest, - ): Promise; - - abstract verifyOrganizationBankAccount( - organizationId: string, - request: VerifyBankAccountRequest, - ): Promise; - - abstract verifyProviderBankAccount( - providerId: string, - request: VerifyBankAccountRequest, - ): Promise; - abstract restartSubscription( organizationId: string, request: OrganizationCreateRequest, diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts index 3254787457a..215fabfd955 100644 --- a/libs/common/src/billing/abstractions/organization-billing.service.ts +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -3,7 +3,6 @@ import { UserId } from "@bitwarden/user-core"; import { OrganizationResponse } from "../../admin-console/models/response/organization.response"; import { InitiationPath } from "../../models/request/reference-event.request"; import { PaymentMethodType, PlanType } from "../enums"; -import { PaymentSourceResponse } from "../models/response/payment-source.response"; export type OrganizationInformation = { name: string; @@ -45,8 +44,6 @@ export type SubscriptionInformation = { }; export abstract class OrganizationBillingServiceAbstraction { - abstract getPaymentSource(organizationId: string): Promise; - abstract purchaseSubscription( subscription: SubscriptionInformation, activeUserId: UserId, diff --git a/libs/common/src/billing/abstractions/tax.service.abstraction.ts b/libs/common/src/billing/abstractions/tax.service.abstraction.ts deleted file mode 100644 index c94fbcba652..00000000000 --- a/libs/common/src/billing/abstractions/tax.service.abstraction.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { CountryListItem } from "../models/domain"; -import { PreviewIndividualInvoiceRequest } from "../models/request/preview-individual-invoice.request"; -import { PreviewOrganizationInvoiceRequest } from "../models/request/preview-organization-invoice.request"; -import { PreviewTaxAmountForOrganizationTrialRequest } from "../models/request/tax"; -import { PreviewInvoiceResponse } from "../models/response/preview-invoice.response"; - -export abstract class TaxServiceAbstraction { - abstract getCountries(): CountryListItem[]; - - abstract isCountrySupported(country: string): Promise; - - abstract previewIndividualInvoice( - request: PreviewIndividualInvoiceRequest, - ): Promise; - - abstract previewOrganizationInvoice( - request: PreviewOrganizationInvoiceRequest, - ): Promise; - - abstract previewTaxAmountForOrganizationTrial: ( - request: PreviewTaxAmountForOrganizationTrialRequest, - ) => Promise; -} diff --git a/libs/common/src/billing/enums/bitwarden-product-type.enum.ts b/libs/common/src/billing/enums/bitwarden-product-type.enum.ts deleted file mode 100644 index 4389d283c00..00000000000 --- a/libs/common/src/billing/enums/bitwarden-product-type.enum.ts +++ /dev/null @@ -1,6 +0,0 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum BitwardenProductType { - PasswordManager = 0, - SecretsManager = 1, -} diff --git a/libs/common/src/billing/enums/index.ts b/libs/common/src/billing/enums/index.ts index 1a9f3f8219c..ee8cd1f5948 100644 --- a/libs/common/src/billing/enums/index.ts +++ b/libs/common/src/billing/enums/index.ts @@ -2,7 +2,6 @@ export * from "./payment-method-type.enum"; export * from "./plan-sponsorship-type.enum"; export * from "./plan-type.enum"; export * from "./transaction-type.enum"; -export * from "./bitwarden-product-type.enum"; export * from "./product-tier-type.enum"; export * from "./product-type.enum"; export * from "./plan-interval.enum"; diff --git a/libs/common/src/billing/models/request/expanded-tax-info-update.request.ts b/libs/common/src/billing/models/request/expanded-tax-info-update.request.ts deleted file mode 100644 index 83b254ac512..00000000000 --- a/libs/common/src/billing/models/request/expanded-tax-info-update.request.ts +++ /dev/null @@ -1,29 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { TaxInformation } from "../domain/tax-information"; - -import { TaxInfoUpdateRequest } from "./tax-info-update.request"; - -export class ExpandedTaxInfoUpdateRequest extends TaxInfoUpdateRequest { - taxId: string; - line1: string; - line2: string; - city: string; - state: string; - - static From(taxInformation: TaxInformation): ExpandedTaxInfoUpdateRequest { - if (!taxInformation) { - return null; - } - - const request = new ExpandedTaxInfoUpdateRequest(); - request.country = taxInformation.country; - request.postalCode = taxInformation.postalCode; - request.taxId = taxInformation.taxId; - request.line1 = taxInformation.line1; - request.line2 = taxInformation.line2; - request.city = taxInformation.city; - request.state = taxInformation.state; - return request; - } -} diff --git a/libs/common/src/billing/models/request/payment.request.ts b/libs/common/src/billing/models/request/payment.request.ts deleted file mode 100644 index e2edd9aabb3..00000000000 --- a/libs/common/src/billing/models/request/payment.request.ts +++ /dev/null @@ -1,10 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { PaymentMethodType } from "../../enums"; - -import { ExpandedTaxInfoUpdateRequest } from "./expanded-tax-info-update.request"; - -export class PaymentRequest extends ExpandedTaxInfoUpdateRequest { - paymentMethodType: PaymentMethodType; - paymentToken: string; -} diff --git a/libs/common/src/billing/models/request/preview-individual-invoice.request.ts b/libs/common/src/billing/models/request/preview-individual-invoice.request.ts deleted file mode 100644 index f817398c629..00000000000 --- a/libs/common/src/billing/models/request/preview-individual-invoice.request.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-strict-ignore -export class PreviewIndividualInvoiceRequest { - passwordManager: PasswordManager; - taxInformation: TaxInformation; - - constructor(passwordManager: PasswordManager, taxInformation: TaxInformation) { - this.passwordManager = passwordManager; - this.taxInformation = taxInformation; - } -} - -class PasswordManager { - additionalStorage: number; - - constructor(additionalStorage: number) { - this.additionalStorage = additionalStorage; - } -} - -class TaxInformation { - postalCode: string; - country: string; - - constructor(postalCode: string, country: string) { - this.postalCode = postalCode; - this.country = country; - } -} diff --git a/libs/common/src/billing/models/request/preview-organization-invoice.request.ts b/libs/common/src/billing/models/request/preview-organization-invoice.request.ts deleted file mode 100644 index a66dc5c46a5..00000000000 --- a/libs/common/src/billing/models/request/preview-organization-invoice.request.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { PlanSponsorshipType, PlanType } from "../../enums"; - -export class PreviewOrganizationInvoiceRequest { - organizationId?: string; - passwordManager: PasswordManager; - secretsManager?: SecretsManager; - taxInformation: TaxInformation; - - constructor( - passwordManager: PasswordManager, - taxInformation: TaxInformation, - organizationId?: string, - secretsManager?: SecretsManager, - ) { - this.organizationId = organizationId; - this.passwordManager = passwordManager; - this.secretsManager = secretsManager; - this.taxInformation = taxInformation; - } -} - -class PasswordManager { - plan: PlanType; - sponsoredPlan?: PlanSponsorshipType; - seats: number; - additionalStorage: number; - - constructor(plan: PlanType, seats: number, additionalStorage: number) { - this.plan = plan; - this.seats = seats; - this.additionalStorage = additionalStorage; - } -} - -class SecretsManager { - seats: number; - additionalMachineAccounts: number; - - constructor(seats: number, additionalMachineAccounts: number) { - this.seats = seats; - this.additionalMachineAccounts = additionalMachineAccounts; - } -} - -class TaxInformation { - postalCode: string; - country: string; - taxId: string | null; - - constructor(postalCode: string, country: string, taxId: string | null) { - this.postalCode = postalCode; - this.country = country; - this.taxId = taxId; - } -} diff --git a/libs/common/src/billing/models/request/tax-info-update.request.ts b/libs/common/src/billing/models/request/tax-info-update.request.ts deleted file mode 100644 index 6f767535472..00000000000 --- a/libs/common/src/billing/models/request/tax-info-update.request.ts +++ /dev/null @@ -1,6 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -export class TaxInfoUpdateRequest { - country: string; - postalCode: string; -} diff --git a/libs/common/src/billing/models/request/tax/index.ts b/libs/common/src/billing/models/request/tax/index.ts deleted file mode 100644 index cda1930c614..00000000000 --- a/libs/common/src/billing/models/request/tax/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./preview-tax-amount-for-organization-trial.request"; diff --git a/libs/common/src/billing/models/request/tax/preview-tax-amount-for-organization-trial.request.ts b/libs/common/src/billing/models/request/tax/preview-tax-amount-for-organization-trial.request.ts deleted file mode 100644 index 3f366335a47..00000000000 --- a/libs/common/src/billing/models/request/tax/preview-tax-amount-for-organization-trial.request.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PlanType, ProductType } from "../../../enums"; - -export type PreviewTaxAmountForOrganizationTrialRequest = { - planType: PlanType; - productType: ProductType; - taxInformation: { - country: string; - postalCode: string; - taxId?: string; - }; -}; diff --git a/libs/common/src/billing/models/request/tokenized-payment-source.request.ts b/libs/common/src/billing/models/request/tokenized-payment-source.request.ts deleted file mode 100644 index e4bf575cc6a..00000000000 --- a/libs/common/src/billing/models/request/tokenized-payment-source.request.ts +++ /dev/null @@ -1,8 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { PaymentMethodType } from "../../enums"; - -export class TokenizedPaymentSourceRequest { - type: PaymentMethodType; - token: string; -} diff --git a/libs/common/src/billing/models/request/update-payment-method.request.ts b/libs/common/src/billing/models/request/update-payment-method.request.ts deleted file mode 100644 index 10b03103716..00000000000 --- a/libs/common/src/billing/models/request/update-payment-method.request.ts +++ /dev/null @@ -1,9 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { ExpandedTaxInfoUpdateRequest } from "./expanded-tax-info-update.request"; -import { TokenizedPaymentSourceRequest } from "./tokenized-payment-source.request"; - -export class UpdatePaymentMethodRequest { - paymentSource: TokenizedPaymentSourceRequest; - taxInformation: ExpandedTaxInfoUpdateRequest; -} diff --git a/libs/common/src/billing/models/request/verify-bank-account.request.ts b/libs/common/src/billing/models/request/verify-bank-account.request.ts deleted file mode 100644 index ee85d1a2aad..00000000000 --- a/libs/common/src/billing/models/request/verify-bank-account.request.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class VerifyBankAccountRequest { - descriptorCode: string; - - constructor(descriptorCode: string) { - this.descriptorCode = descriptorCode; - } -} diff --git a/libs/common/src/billing/models/response/billing-payment.response.ts b/libs/common/src/billing/models/response/billing-payment.response.ts deleted file mode 100644 index e60a11c0772..00000000000 --- a/libs/common/src/billing/models/response/billing-payment.response.ts +++ /dev/null @@ -1,17 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { BaseResponse } from "../../../models/response/base.response"; - -import { BillingSourceResponse } from "./billing.response"; - -export class BillingPaymentResponse extends BaseResponse { - balance: number; - paymentSource: BillingSourceResponse; - - constructor(response: any) { - super(response); - this.balance = this.getResponseProperty("Balance"); - const paymentSource = this.getResponseProperty("PaymentSource"); - this.paymentSource = paymentSource == null ? null : new BillingSourceResponse(paymentSource); - } -} diff --git a/libs/common/src/billing/models/response/payment-method.response.ts b/libs/common/src/billing/models/response/payment-method.response.ts deleted file mode 100644 index 34e95032aef..00000000000 --- a/libs/common/src/billing/models/response/payment-method.response.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BaseResponse } from "../../../models/response/base.response"; - -import { PaymentSourceResponse } from "./payment-source.response"; -import { TaxInfoResponse } from "./tax-info.response"; - -export class PaymentMethodResponse extends BaseResponse { - accountCredit: number; - paymentSource?: PaymentSourceResponse; - subscriptionStatus?: string; - taxInformation?: TaxInfoResponse; - - constructor(response: any) { - super(response); - this.accountCredit = this.getResponseProperty("AccountCredit"); - - const paymentSource = this.getResponseProperty("PaymentSource"); - if (paymentSource) { - this.paymentSource = new PaymentSourceResponse(paymentSource); - } - - this.subscriptionStatus = this.getResponseProperty("SubscriptionStatus"); - - const taxInformation = this.getResponseProperty("TaxInformation"); - if (taxInformation) { - this.taxInformation = new TaxInfoResponse(taxInformation); - } - } -} diff --git a/libs/common/src/billing/models/response/tax-id-types.response.ts b/libs/common/src/billing/models/response/tax-id-types.response.ts deleted file mode 100644 index f31f2133b34..00000000000 --- a/libs/common/src/billing/models/response/tax-id-types.response.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BaseResponse } from "../../../models/response/base.response"; - -export class TaxIdTypesResponse extends BaseResponse { - taxIdTypes: TaxIdTypeResponse[] = []; - - constructor(response: any) { - super(response); - const taxIdTypes = this.getResponseProperty("TaxIdTypes"); - if (taxIdTypes && taxIdTypes.length) { - this.taxIdTypes = taxIdTypes.map((t: any) => new TaxIdTypeResponse(t)); - } - } -} - -export class TaxIdTypeResponse extends BaseResponse { - code: string; - country: string; - description: string; - example: string; - - constructor(response: any) { - super(response); - this.code = this.getResponseProperty("Code"); - this.country = this.getResponseProperty("Country"); - this.description = this.getResponseProperty("Description"); - this.example = this.getResponseProperty("Example"); - } -} diff --git a/libs/common/src/billing/models/response/tax/index.ts b/libs/common/src/billing/models/response/tax/index.ts deleted file mode 100644 index 525d6d7c80a..00000000000 --- a/libs/common/src/billing/models/response/tax/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./preview-tax-amount.response"; diff --git a/libs/common/src/billing/models/response/tax/preview-tax-amount.response.ts b/libs/common/src/billing/models/response/tax/preview-tax-amount.response.ts deleted file mode 100644 index cf15156551a..00000000000 --- a/libs/common/src/billing/models/response/tax/preview-tax-amount.response.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { BaseResponse } from "../../../../models/response/base.response"; - -export class PreviewTaxAmountResponse extends BaseResponse { - taxAmount: number; - - constructor(response: any) { - super(response); - - this.taxAmount = this.getResponseProperty("TaxAmount"); - } -} diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index 2292f26e616..a34809e9f02 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -1,23 +1,16 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response"; - import { ApiService } from "../../abstractions/api.service"; import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response"; import { ListResponse } from "../../models/response/list.response"; import { BillingApiServiceAbstraction } from "../abstractions"; -import { PaymentMethodType } from "../enums"; import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request"; -import { ExpandedTaxInfoUpdateRequest } from "../models/request/expanded-tax-info-update.request"; import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request"; import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request"; -import { UpdatePaymentMethodRequest } from "../models/request/update-payment-method.request"; -import { VerifyBankAccountRequest } from "../models/request/verify-bank-account.request"; import { InvoicesResponse } from "../models/response/invoices.response"; import { OrganizationBillingMetadataResponse } from "../models/response/organization-billing-metadata.response"; -import { PaymentMethodResponse } from "../models/response/payment-method.response"; import { PlanResponse } from "../models/response/plan.response"; import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response"; @@ -54,21 +47,6 @@ export class BillingApiService implements BillingApiServiceAbstraction { ); } - async createSetupIntent(type: PaymentMethodType) { - const getPath = () => { - switch (type) { - case PaymentMethodType.BankAccount: { - return "/setup-intent/bank-account"; - } - case PaymentMethodType.Card: { - return "/setup-intent/card"; - } - } - }; - const response = await this.apiService.send("POST", getPath(), null, true, true); - return response as string; - } - async getOrganizationBillingMetadata( organizationId: string, ): Promise { @@ -83,17 +61,6 @@ export class BillingApiService implements BillingApiServiceAbstraction { return new OrganizationBillingMetadataResponse(r); } - async getOrganizationPaymentMethod(organizationId: string): Promise { - const response = await this.apiService.send( - "GET", - "/organizations/" + organizationId + "/billing/payment-method", - null, - true, - true, - ); - return new PaymentMethodResponse(response); - } - async getPlans(): Promise> { const r = await this.apiService.send("GET", "/plans", null, false, true); return new ListResponse(r, PlanResponse); @@ -145,43 +112,6 @@ export class BillingApiService implements BillingApiServiceAbstraction { return new ProviderSubscriptionResponse(response); } - async getProviderTaxInformation(providerId: string): Promise { - const response = await this.apiService.send( - "GET", - "/providers/" + providerId + "/billing/tax-information", - null, - true, - true, - ); - return new TaxInfoResponse(response); - } - - async updateOrganizationPaymentMethod( - organizationId: string, - request: UpdatePaymentMethodRequest, - ): Promise { - return await this.apiService.send( - "PUT", - "/organizations/" + organizationId + "/billing/payment-method", - request, - true, - false, - ); - } - - async updateOrganizationTaxInformation( - organizationId: string, - request: ExpandedTaxInfoUpdateRequest, - ): Promise { - return await this.apiService.send( - "PUT", - "/organizations/" + organizationId + "/billing/tax-information", - request, - true, - false, - ); - } - async updateProviderClientOrganization( providerId: string, organizationId: string, @@ -196,55 +126,6 @@ export class BillingApiService implements BillingApiServiceAbstraction { ); } - async updateProviderPaymentMethod( - providerId: string, - request: UpdatePaymentMethodRequest, - ): Promise { - return await this.apiService.send( - "PUT", - "/providers/" + providerId + "/billing/payment-method", - request, - true, - false, - ); - } - - async updateProviderTaxInformation(providerId: string, request: ExpandedTaxInfoUpdateRequest) { - return await this.apiService.send( - "PUT", - "/providers/" + providerId + "/billing/tax-information", - request, - true, - false, - ); - } - - async verifyOrganizationBankAccount( - organizationId: string, - request: VerifyBankAccountRequest, - ): Promise { - return await this.apiService.send( - "POST", - "/organizations/" + organizationId + "/billing/payment-method/verify-bank-account", - request, - true, - false, - ); - } - - async verifyProviderBankAccount( - providerId: string, - request: VerifyBankAccountRequest, - ): Promise { - return await this.apiService.send( - "POST", - "/providers/" + providerId + "/billing/payment-method/verify-bank-account", - request, - true, - false, - ); - } - async restartSubscription( organizationId: string, request: OrganizationCreateRequest, diff --git a/libs/common/src/billing/services/organization-billing.service.spec.ts b/libs/common/src/billing/services/organization-billing.service.spec.ts index 42cfb4a5371..a14dd0f0279 100644 --- a/libs/common/src/billing/services/organization-billing.service.spec.ts +++ b/libs/common/src/billing/services/organization-billing.service.spec.ts @@ -23,7 +23,6 @@ import { OrganizationResponse } from "../../admin-console/models/response/organi import { EncString } from "../../key-management/crypto/models/enc-string"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { OrgKey } from "../../types/key"; -import { PaymentMethodResponse } from "../models/response/payment-method.response"; describe("OrganizationBillingService", () => { let apiService: jest.Mocked; @@ -62,47 +61,6 @@ describe("OrganizationBillingService", () => { return jest.resetAllMocks(); }); - describe("getPaymentSource()", () => { - it("given a valid organization id, then it returns a payment source", async () => { - //Arrange - const orgId = "organization-test"; - const paymentMethodResponse = { - paymentSource: { type: PaymentMethodType.Card }, - } as PaymentMethodResponse; - billingApiService.getOrganizationPaymentMethod.mockResolvedValue(paymentMethodResponse); - - //Act - const returnedPaymentSource = await sut.getPaymentSource(orgId); - - //Assert - expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1); - expect(returnedPaymentSource).toEqual(paymentMethodResponse.paymentSource); - }); - - it("given an invalid organizationId, it should return undefined", async () => { - //Arrange - const orgId = "invalid-id"; - billingApiService.getOrganizationPaymentMethod.mockResolvedValue(null); - - //Act - const returnedPaymentSource = await sut.getPaymentSource(orgId); - - //Assert - expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1); - expect(returnedPaymentSource).toBeUndefined(); - }); - - it("given an API error occurs, then it throws the error", async () => { - // Arrange - const orgId = "error-org"; - billingApiService.getOrganizationPaymentMethod.mockRejectedValue(new Error("API Error")); - - // Act & Assert - await expect(sut.getPaymentSource(orgId)).rejects.toThrow("API Error"); - expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1); - }); - }); - describe("purchaseSubscription()", () => { it("given valid subscription information, then it returns successful response", async () => { //Arrange @@ -118,7 +76,7 @@ describe("OrganizationBillingService", () => { const organizationResponse = { name: subscriptionInformation.organization.name, billingEmail: subscriptionInformation.organization.billingEmail, - planType: subscriptionInformation.plan.type, + planType: subscriptionInformation.plan!.type, } as OrganizationResponse; organizationApiService.create.mockResolvedValue(organizationResponse); @@ -201,8 +159,8 @@ describe("OrganizationBillingService", () => { const organizationResponse = { name: subscriptionInformation.organization.name, - plan: { type: subscriptionInformation.plan.type }, - planType: subscriptionInformation.plan.type, + plan: { type: subscriptionInformation.plan!.type }, + planType: subscriptionInformation.plan!.type, } as OrganizationResponse; organizationApiService.createWithoutPayment.mockResolvedValue(organizationResponse); @@ -262,7 +220,7 @@ describe("OrganizationBillingService", () => { const organizationResponse = { name: subscriptionInformation.organization.name, billingEmail: subscriptionInformation.organization.billingEmail, - planType: subscriptionInformation.plan.type, + planType: subscriptionInformation.plan!.type, } as OrganizationResponse; organizationApiService.create.mockResolvedValue(organizationResponse); diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index 53ce727df68..4120047a15f 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -25,7 +25,6 @@ import { } from "../abstractions"; import { PlanType } from "../enums"; import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request"; -import { PaymentSourceResponse } from "../models/response/payment-source.response"; interface OrganizationKeys { encryptedKey: EncString; @@ -45,11 +44,6 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs private syncService: SyncService, ) {} - async getPaymentSource(organizationId: string): Promise { - const paymentMethod = await this.billingApiService.getOrganizationPaymentMethod(organizationId); - return paymentMethod?.paymentSource; - } - async purchaseSubscription( subscription: SubscriptionInformation, activeUserId: UserId, diff --git a/libs/common/src/billing/services/tax.service.ts b/libs/common/src/billing/services/tax.service.ts deleted file mode 100644 index 27966016913..00000000000 --- a/libs/common/src/billing/services/tax.service.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { PreviewTaxAmountForOrganizationTrialRequest } from "@bitwarden/common/billing/models/request/tax"; - -import { ApiService } from "../../abstractions/api.service"; -import { TaxServiceAbstraction } from "../abstractions/tax.service.abstraction"; -import { CountryListItem } from "../models/domain"; -import { PreviewIndividualInvoiceRequest } from "../models/request/preview-individual-invoice.request"; -import { PreviewOrganizationInvoiceRequest } from "../models/request/preview-organization-invoice.request"; -import { PreviewInvoiceResponse } from "../models/response/preview-invoice.response"; - -export class TaxService implements TaxServiceAbstraction { - constructor(private apiService: ApiService) {} - - getCountries(): CountryListItem[] { - return [ - { name: "-- Select --", value: "", disabled: false }, - { name: "United States", value: "US", disabled: false }, - { name: "China", value: "CN", disabled: false }, - { name: "France", value: "FR", disabled: false }, - { name: "Germany", value: "DE", disabled: false }, - { name: "Canada", value: "CA", disabled: false }, - { name: "United Kingdom", value: "GB", disabled: false }, - { name: "Australia", value: "AU", disabled: false }, - { name: "India", value: "IN", disabled: false }, - { name: "", value: "-", disabled: true }, - { name: "Afghanistan", value: "AF", disabled: false }, - { name: "Åland Islands", value: "AX", disabled: false }, - { name: "Albania", value: "AL", disabled: false }, - { name: "Algeria", value: "DZ", disabled: false }, - { name: "American Samoa", value: "AS", disabled: false }, - { name: "Andorra", value: "AD", disabled: false }, - { name: "Angola", value: "AO", disabled: false }, - { name: "Anguilla", value: "AI", disabled: false }, - { name: "Antarctica", value: "AQ", disabled: false }, - { name: "Antigua and Barbuda", value: "AG", disabled: false }, - { name: "Argentina", value: "AR", disabled: false }, - { name: "Armenia", value: "AM", disabled: false }, - { name: "Aruba", value: "AW", disabled: false }, - { name: "Austria", value: "AT", disabled: false }, - { name: "Azerbaijan", value: "AZ", disabled: false }, - { name: "Bahamas", value: "BS", disabled: false }, - { name: "Bahrain", value: "BH", disabled: false }, - { name: "Bangladesh", value: "BD", disabled: false }, - { name: "Barbados", value: "BB", disabled: false }, - { name: "Belarus", value: "BY", disabled: false }, - { name: "Belgium", value: "BE", disabled: false }, - { name: "Belize", value: "BZ", disabled: false }, - { name: "Benin", value: "BJ", disabled: false }, - { name: "Bermuda", value: "BM", disabled: false }, - { name: "Bhutan", value: "BT", disabled: false }, - { name: "Bolivia, Plurinational State of", value: "BO", disabled: false }, - { name: "Bonaire, Sint Eustatius and Saba", value: "BQ", disabled: false }, - { name: "Bosnia and Herzegovina", value: "BA", disabled: false }, - { name: "Botswana", value: "BW", disabled: false }, - { name: "Bouvet Island", value: "BV", disabled: false }, - { name: "Brazil", value: "BR", disabled: false }, - { name: "British Indian Ocean Territory", value: "IO", disabled: false }, - { name: "Brunei Darussalam", value: "BN", disabled: false }, - { name: "Bulgaria", value: "BG", disabled: false }, - { name: "Burkina Faso", value: "BF", disabled: false }, - { name: "Burundi", value: "BI", disabled: false }, - { name: "Cambodia", value: "KH", disabled: false }, - { name: "Cameroon", value: "CM", disabled: false }, - { name: "Cape Verde", value: "CV", disabled: false }, - { name: "Cayman Islands", value: "KY", disabled: false }, - { name: "Central African Republic", value: "CF", disabled: false }, - { name: "Chad", value: "TD", disabled: false }, - { name: "Chile", value: "CL", disabled: false }, - { name: "Christmas Island", value: "CX", disabled: false }, - { name: "Cocos (Keeling) Islands", value: "CC", disabled: false }, - { name: "Colombia", value: "CO", disabled: false }, - { name: "Comoros", value: "KM", disabled: false }, - { name: "Congo", value: "CG", disabled: false }, - { name: "Congo, the Democratic Republic of the", value: "CD", disabled: false }, - { name: "Cook Islands", value: "CK", disabled: false }, - { name: "Costa Rica", value: "CR", disabled: false }, - { name: "Côte d'Ivoire", value: "CI", disabled: false }, - { name: "Croatia", value: "HR", disabled: false }, - { name: "Cuba", value: "CU", disabled: false }, - { name: "Curaçao", value: "CW", disabled: false }, - { name: "Cyprus", value: "CY", disabled: false }, - { name: "Czech Republic", value: "CZ", disabled: false }, - { name: "Denmark", value: "DK", disabled: false }, - { name: "Djibouti", value: "DJ", disabled: false }, - { name: "Dominica", value: "DM", disabled: false }, - { name: "Dominican Republic", value: "DO", disabled: false }, - { name: "Ecuador", value: "EC", disabled: false }, - { name: "Egypt", value: "EG", disabled: false }, - { name: "El Salvador", value: "SV", disabled: false }, - { name: "Equatorial Guinea", value: "GQ", disabled: false }, - { name: "Eritrea", value: "ER", disabled: false }, - { name: "Estonia", value: "EE", disabled: false }, - { name: "Ethiopia", value: "ET", disabled: false }, - { name: "Falkland Islands (Malvinas)", value: "FK", disabled: false }, - { name: "Faroe Islands", value: "FO", disabled: false }, - { name: "Fiji", value: "FJ", disabled: false }, - { name: "Finland", value: "FI", disabled: false }, - { name: "French Guiana", value: "GF", disabled: false }, - { name: "French Polynesia", value: "PF", disabled: false }, - { name: "French Southern Territories", value: "TF", disabled: false }, - { name: "Gabon", value: "GA", disabled: false }, - { name: "Gambia", value: "GM", disabled: false }, - { name: "Georgia", value: "GE", disabled: false }, - { name: "Ghana", value: "GH", disabled: false }, - { name: "Gibraltar", value: "GI", disabled: false }, - { name: "Greece", value: "GR", disabled: false }, - { name: "Greenland", value: "GL", disabled: false }, - { name: "Grenada", value: "GD", disabled: false }, - { name: "Guadeloupe", value: "GP", disabled: false }, - { name: "Guam", value: "GU", disabled: false }, - { name: "Guatemala", value: "GT", disabled: false }, - { name: "Guernsey", value: "GG", disabled: false }, - { name: "Guinea", value: "GN", disabled: false }, - { name: "Guinea-Bissau", value: "GW", disabled: false }, - { name: "Guyana", value: "GY", disabled: false }, - { name: "Haiti", value: "HT", disabled: false }, - { name: "Heard Island and McDonald Islands", value: "HM", disabled: false }, - { name: "Holy See (Vatican City State)", value: "VA", disabled: false }, - { name: "Honduras", value: "HN", disabled: false }, - { name: "Hong Kong", value: "HK", disabled: false }, - { name: "Hungary", value: "HU", disabled: false }, - { name: "Iceland", value: "IS", disabled: false }, - { name: "Indonesia", value: "ID", disabled: false }, - { name: "Iran, Islamic Republic of", value: "IR", disabled: false }, - { name: "Iraq", value: "IQ", disabled: false }, - { name: "Ireland", value: "IE", disabled: false }, - { name: "Isle of Man", value: "IM", disabled: false }, - { name: "Israel", value: "IL", disabled: false }, - { name: "Italy", value: "IT", disabled: false }, - { name: "Jamaica", value: "JM", disabled: false }, - { name: "Japan", value: "JP", disabled: false }, - { name: "Jersey", value: "JE", disabled: false }, - { name: "Jordan", value: "JO", disabled: false }, - { name: "Kazakhstan", value: "KZ", disabled: false }, - { name: "Kenya", value: "KE", disabled: false }, - { name: "Kiribati", value: "KI", disabled: false }, - { name: "Korea, Democratic People's Republic of", value: "KP", disabled: false }, - { name: "Korea, Republic of", value: "KR", disabled: false }, - { name: "Kuwait", value: "KW", disabled: false }, - { name: "Kyrgyzstan", value: "KG", disabled: false }, - { name: "Lao People's Democratic Republic", value: "LA", disabled: false }, - { name: "Latvia", value: "LV", disabled: false }, - { name: "Lebanon", value: "LB", disabled: false }, - { name: "Lesotho", value: "LS", disabled: false }, - { name: "Liberia", value: "LR", disabled: false }, - { name: "Libya", value: "LY", disabled: false }, - { name: "Liechtenstein", value: "LI", disabled: false }, - { name: "Lithuania", value: "LT", disabled: false }, - { name: "Luxembourg", value: "LU", disabled: false }, - { name: "Macao", value: "MO", disabled: false }, - { name: "Macedonia, the former Yugoslav Republic of", value: "MK", disabled: false }, - { name: "Madagascar", value: "MG", disabled: false }, - { name: "Malawi", value: "MW", disabled: false }, - { name: "Malaysia", value: "MY", disabled: false }, - { name: "Maldives", value: "MV", disabled: false }, - { name: "Mali", value: "ML", disabled: false }, - { name: "Malta", value: "MT", disabled: false }, - { name: "Marshall Islands", value: "MH", disabled: false }, - { name: "Martinique", value: "MQ", disabled: false }, - { name: "Mauritania", value: "MR", disabled: false }, - { name: "Mauritius", value: "MU", disabled: false }, - { name: "Mayotte", value: "YT", disabled: false }, - { name: "Mexico", value: "MX", disabled: false }, - { name: "Micronesia, Federated States of", value: "FM", disabled: false }, - { name: "Moldova, Republic of", value: "MD", disabled: false }, - { name: "Monaco", value: "MC", disabled: false }, - { name: "Mongolia", value: "MN", disabled: false }, - { name: "Montenegro", value: "ME", disabled: false }, - { name: "Montserrat", value: "MS", disabled: false }, - { name: "Morocco", value: "MA", disabled: false }, - { name: "Mozambique", value: "MZ", disabled: false }, - { name: "Myanmar", value: "MM", disabled: false }, - { name: "Namibia", value: "NA", disabled: false }, - { name: "Nauru", value: "NR", disabled: false }, - { name: "Nepal", value: "NP", disabled: false }, - { name: "Netherlands", value: "NL", disabled: false }, - { name: "New Caledonia", value: "NC", disabled: false }, - { name: "New Zealand", value: "NZ", disabled: false }, - { name: "Nicaragua", value: "NI", disabled: false }, - { name: "Niger", value: "NE", disabled: false }, - { name: "Nigeria", value: "NG", disabled: false }, - { name: "Niue", value: "NU", disabled: false }, - { name: "Norfolk Island", value: "NF", disabled: false }, - { name: "Northern Mariana Islands", value: "MP", disabled: false }, - { name: "Norway", value: "NO", disabled: false }, - { name: "Oman", value: "OM", disabled: false }, - { name: "Pakistan", value: "PK", disabled: false }, - { name: "Palau", value: "PW", disabled: false }, - { name: "Palestinian Territory, Occupied", value: "PS", disabled: false }, - { name: "Panama", value: "PA", disabled: false }, - { name: "Papua New Guinea", value: "PG", disabled: false }, - { name: "Paraguay", value: "PY", disabled: false }, - { name: "Peru", value: "PE", disabled: false }, - { name: "Philippines", value: "PH", disabled: false }, - { name: "Pitcairn", value: "PN", disabled: false }, - { name: "Poland", value: "PL", disabled: false }, - { name: "Portugal", value: "PT", disabled: false }, - { name: "Puerto Rico", value: "PR", disabled: false }, - { name: "Qatar", value: "QA", disabled: false }, - { name: "Réunion", value: "RE", disabled: false }, - { name: "Romania", value: "RO", disabled: false }, - { name: "Russian Federation", value: "RU", disabled: false }, - { name: "Rwanda", value: "RW", disabled: false }, - { name: "Saint Barthélemy", value: "BL", disabled: false }, - { name: "Saint Helena, Ascension and Tristan da Cunha", value: "SH", disabled: false }, - { name: "Saint Kitts and Nevis", value: "KN", disabled: false }, - { name: "Saint Lucia", value: "LC", disabled: false }, - { name: "Saint Martin (French part)", value: "MF", disabled: false }, - { name: "Saint Pierre and Miquelon", value: "PM", disabled: false }, - { name: "Saint Vincent and the Grenadines", value: "VC", disabled: false }, - { name: "Samoa", value: "WS", disabled: false }, - { name: "San Marino", value: "SM", disabled: false }, - { name: "Sao Tome and Principe", value: "ST", disabled: false }, - { name: "Saudi Arabia", value: "SA", disabled: false }, - { name: "Senegal", value: "SN", disabled: false }, - { name: "Serbia", value: "RS", disabled: false }, - { name: "Seychelles", value: "SC", disabled: false }, - { name: "Sierra Leone", value: "SL", disabled: false }, - { name: "Singapore", value: "SG", disabled: false }, - { name: "Sint Maarten (Dutch part)", value: "SX", disabled: false }, - { name: "Slovakia", value: "SK", disabled: false }, - { name: "Slovenia", value: "SI", disabled: false }, - { name: "Solomon Islands", value: "SB", disabled: false }, - { name: "Somalia", value: "SO", disabled: false }, - { name: "South Africa", value: "ZA", disabled: false }, - { name: "South Georgia and the South Sandwich Islands", value: "GS", disabled: false }, - { name: "South Sudan", value: "SS", disabled: false }, - { name: "Spain", value: "ES", disabled: false }, - { name: "Sri Lanka", value: "LK", disabled: false }, - { name: "Sudan", value: "SD", disabled: false }, - { name: "Suriname", value: "SR", disabled: false }, - { name: "Svalbard and Jan Mayen", value: "SJ", disabled: false }, - { name: "Swaziland", value: "SZ", disabled: false }, - { name: "Sweden", value: "SE", disabled: false }, - { name: "Switzerland", value: "CH", disabled: false }, - { name: "Syrian Arab Republic", value: "SY", disabled: false }, - { name: "Taiwan", value: "TW", disabled: false }, - { name: "Tajikistan", value: "TJ", disabled: false }, - { name: "Tanzania, United Republic of", value: "TZ", disabled: false }, - { name: "Thailand", value: "TH", disabled: false }, - { name: "Timor-Leste", value: "TL", disabled: false }, - { name: "Togo", value: "TG", disabled: false }, - { name: "Tokelau", value: "TK", disabled: false }, - { name: "Tonga", value: "TO", disabled: false }, - { name: "Trinidad and Tobago", value: "TT", disabled: false }, - { name: "Tunisia", value: "TN", disabled: false }, - { name: "Turkey", value: "TR", disabled: false }, - { name: "Turkmenistan", value: "TM", disabled: false }, - { name: "Turks and Caicos Islands", value: "TC", disabled: false }, - { name: "Tuvalu", value: "TV", disabled: false }, - { name: "Uganda", value: "UG", disabled: false }, - { name: "Ukraine", value: "UA", disabled: false }, - { name: "United Arab Emirates", value: "AE", disabled: false }, - { name: "United States Minor Outlying Islands", value: "UM", disabled: false }, - { name: "Uruguay", value: "UY", disabled: false }, - { name: "Uzbekistan", value: "UZ", disabled: false }, - { name: "Vanuatu", value: "VU", disabled: false }, - { name: "Venezuela, Bolivarian Republic of", value: "VE", disabled: false }, - { name: "Viet Nam", value: "VN", disabled: false }, - { name: "Virgin Islands, British", value: "VG", disabled: false }, - { name: "Virgin Islands, U.S.", value: "VI", disabled: false }, - { name: "Wallis and Futuna", value: "WF", disabled: false }, - { name: "Western Sahara", value: "EH", disabled: false }, - { name: "Yemen", value: "YE", disabled: false }, - { name: "Zambia", value: "ZM", disabled: false }, - { name: "Zimbabwe", value: "ZW", disabled: false }, - ]; - } - - async isCountrySupported(country: string): Promise { - const response = await this.apiService.send( - "GET", - "/tax/is-country-supported?country=" + country, - null, - true, - true, - ); - return response; - } - - async previewIndividualInvoice( - request: PreviewIndividualInvoiceRequest, - ): Promise { - const response = await this.apiService.send( - "POST", - "/accounts/billing/preview-invoice", - request, - true, - true, - ); - return new PreviewInvoiceResponse(response); - } - - async previewOrganizationInvoice( - request: PreviewOrganizationInvoiceRequest, - ): Promise { - const response = await this.apiService.send( - "POST", - `/invoices/preview-organization`, - request, - true, - true, - ); - return new PreviewInvoiceResponse(response); - } - - async previewTaxAmountForOrganizationTrial( - request: PreviewTaxAmountForOrganizationTrialRequest, - ): Promise { - const response = await this.apiService.send( - "POST", - "/tax/preview-amount/organization-trial", - request, - true, - true, - ); - return response as number; - } -} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 67836befd7c..578d09c9aea 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -23,7 +23,6 @@ export enum FeatureFlag { /* Billing */ TrialPaymentOptional = "PM-8163-trial-payment", PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", - PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", @@ -100,7 +99,6 @@ export const DefaultFeatureFlagValue = { /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, - [FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index 3c3e1c3677f..352e45b88b1 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -292,5 +292,100 @@ describe("DefaultSyncService", () => { expect(masterPasswordAbstraction.setMasterPasswordUnlockData).not.toHaveBeenCalled(); }); }); + + describe("mutate 'last update time'", () => { + let mockUserState: { update: jest.Mock }; + + const setupMockUserState = () => { + const mockUserState = { update: jest.fn() }; + jest.spyOn(stateProvider, "getUser").mockReturnValue(mockUserState as any); + return mockUserState; + }; + + const setupSyncScenario = (revisionDate: Date, lastSyncDate: Date) => { + jest.spyOn(apiService, "getAccountRevisionDate").mockResolvedValue(revisionDate.getTime()); + jest.spyOn(sut as any, "getLastSync").mockResolvedValue(lastSyncDate); + }; + + const expectUpdateCallCount = ( + mockUserState: { update: jest.Mock }, + expectedCount: number, + ) => { + if (expectedCount === 0) { + expect(mockUserState.update).not.toHaveBeenCalled(); + } else { + expect(mockUserState.update).toHaveBeenCalledTimes(expectedCount); + } + }; + + const defaultSyncOptions = { allowThrowOnError: true, skipTokenRefresh: true }; + const errorTolerantSyncOptions = { allowThrowOnError: false, skipTokenRefresh: true }; + + beforeEach(() => { + mockUserState = setupMockUserState(); + }); + + it("uses the current time when a sync is forced", async () => { + // Mock the value of this observable because it's used in `syncProfile`. Without it, the test breaks. + keyConnectorService.convertAccountRequired$ = of(false); + + // Baseline date/time to compare sync time to, in order to avoid needing to use some kind of fake date provider. + const beforeSync = Date.now(); + + // send it! + await sut.fullSync(true, defaultSyncOptions); + + expectUpdateCallCount(mockUserState, 1); + // Get the first and only call to update(...) + const updateCall = mockUserState.update.mock.calls[0]; + // Get the first argument to update(...) -- this will be the date callback that returns the date of the last successful sync + const dateCallback = updateCall[0]; + const actualTime = dateCallback() as Date; + + expect(Math.abs(actualTime.getTime() - beforeSync)).toBeLessThan(1); + }); + + it("updates last sync time when no sync is necessary", async () => { + const revisionDate = new Date(1); + setupSyncScenario(revisionDate, revisionDate); + + const syncResult = await sut.fullSync(false, defaultSyncOptions); + + // Sync should complete but return false since no sync was needed + expect(syncResult).toBe(false); + expectUpdateCallCount(mockUserState, 1); + }); + + it("updates last sync time when sync is successful", async () => { + setupSyncScenario(new Date(2), new Date(1)); + + const syncResult = await sut.fullSync(false, defaultSyncOptions); + + expect(syncResult).toBe(true); + expectUpdateCallCount(mockUserState, 1); + }); + + describe("error scenarios", () => { + it("does not update last sync time when sync fails", async () => { + apiService.getSync.mockRejectedValue(new Error("not connected")); + + const syncResult = await sut.fullSync(true, errorTolerantSyncOptions); + + expect(syncResult).toBe(false); + expectUpdateCallCount(mockUserState, 0); + }); + + it("does not update last sync time when account revision check fails", async () => { + jest + .spyOn(apiService, "getAccountRevisionDate") + .mockRejectedValue(new Error("not connected")); + + const syncResult = await sut.fullSync(false, errorTolerantSyncOptions); + + expect(syncResult).toBe(false); + expectUpdateCallCount(mockUserState, 0); + }); + }); + }); }); }); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 582e4b58a64..a02d602dbf0 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -134,9 +134,11 @@ export class DefaultSyncService extends CoreSyncService { const now = new Date(); let needsSync = false; + let needsSyncSucceeded = true; try { needsSync = await this.needsSyncing(forceSync); } catch (e) { + needsSyncSucceeded = false; if (allowThrowOnError) { this.syncCompleted(false, userId); throw e; @@ -144,7 +146,9 @@ export class DefaultSyncService extends CoreSyncService { } if (!needsSync) { - await this.setLastSync(now, userId); + if (needsSyncSucceeded) { + await this.setLastSync(now, userId); + } return this.syncCompleted(false, userId); } diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 70ba76fe797..b10df69e277 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -90,14 +90,10 @@ import { } from "../auth/models/response/two-factor-web-authn.response"; import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response"; import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request"; -import { PaymentRequest } from "../billing/models/request/payment.request"; -import { TaxInfoUpdateRequest } from "../billing/models/request/tax-info-update.request"; import { BillingHistoryResponse } from "../billing/models/response/billing-history.response"; -import { BillingPaymentResponse } from "../billing/models/response/billing-payment.response"; import { PaymentResponse } from "../billing/models/response/payment.response"; import { PlanResponse } from "../billing/models/response/plan.response"; import { SubscriptionResponse } from "../billing/models/response/subscription.response"; -import { TaxInfoResponse } from "../billing/models/response/tax-info.response"; import { ClientType, DeviceType } from "../enums"; import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request"; import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request"; @@ -294,11 +290,6 @@ export class ApiService implements ApiServiceAbstraction { return new SubscriptionResponse(r); } - async getTaxInfo(): Promise { - const r = await this.send("GET", "/accounts/tax", null, true, true); - return new TaxInfoResponse(r); - } - async putProfile(request: UpdateProfileRequest): Promise { const r = await this.send("PUT", "/accounts/profile", request, true, true); return new ProfileResponse(r); @@ -309,10 +300,6 @@ export class ApiService implements ApiServiceAbstraction { return new ProfileResponse(r); } - putTaxInfo(request: TaxInfoUpdateRequest): Promise { - return this.send("PUT", "/accounts/tax", request, true, false); - } - async postPrelogin(request: PreloginRequest): Promise { const env = await firstValueFrom(this.environmentService.environment$); const r = await this.send( @@ -365,10 +352,6 @@ export class ApiService implements ApiServiceAbstraction { return new PaymentResponse(r); } - postAccountPayment(request: PaymentRequest): Promise { - return this.send("POST", "/accounts/payment", request, true, false); - } - postAccountLicense(data: FormData): Promise { return this.send("POST", "/accounts/license", data, true, false); } @@ -429,11 +412,6 @@ export class ApiService implements ApiServiceAbstraction { return new BillingHistoryResponse(r); } - async getUserBillingPayment(): Promise { - const r = await this.send("GET", "/accounts/billing/payment-method", null, true, true); - return new BillingPaymentResponse(r); - } - // Cipher APIs async getCipher(id: string): Promise { diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index 6bb6ccf10bd..d712d5cb2b8 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -130,7 +130,11 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE .concat(sizes[this.size()]) .concat( this.showDisabledStyles() || this.disabled() - ? ["aria-disabled:tw-opacity-60", "aria-disabled:hover:!tw-bg-transparent"] + ? [ + "aria-disabled:tw-opacity-60", + "aria-disabled:hover:!tw-bg-transparent", + "tw-cursor-default", + ] : [], ); } diff --git a/libs/components/src/popover/popover.stories.ts b/libs/components/src/popover/popover.stories.ts index 6d2cf77e47d..596381d0777 100644 --- a/libs/components/src/popover/popover.stories.ts +++ b/libs/components/src/popover/popover.stories.ts @@ -23,6 +23,7 @@ export default { useFactory: () => { return new I18nMockService({ close: "Close", + loading: "Loading", }); }, }, diff --git a/libs/components/src/tooltip/index.ts b/libs/components/src/tooltip/index.ts new file mode 100644 index 00000000000..28c35fd6ee6 --- /dev/null +++ b/libs/components/src/tooltip/index.ts @@ -0,0 +1 @@ +export * from "./tooltip.directive"; diff --git a/libs/components/src/tooltip/tooltip-positions.ts b/libs/components/src/tooltip/tooltip-positions.ts new file mode 100644 index 00000000000..6396bb6632e --- /dev/null +++ b/libs/components/src/tooltip/tooltip-positions.ts @@ -0,0 +1,61 @@ +import { ConnectedPosition } from "@angular/cdk/overlay"; + +const ORIGIN_OFFSET_PX = 10; + +export type TooltipPositionIdentifier = + | "right-center" + | "left-center" + | "below-center" + | "above-center"; + +export interface TooltipPosition extends ConnectedPosition { + id: TooltipPositionIdentifier; +} + +export const tooltipPositions: TooltipPosition[] = [ + /** + * The order of these positions matters. The Tooltip component will use + * the first position that fits within the viewport. + */ + + // Tooltip opens to right of trigger + { + id: "right-center", + offsetX: ORIGIN_OFFSET_PX, + originX: "end", + originY: "center", + overlayX: "start", + overlayY: "center", + panelClass: ["bit-tooltip-right-center"], + }, + // ... to left of trigger + { + id: "left-center", + offsetX: -ORIGIN_OFFSET_PX, + originX: "start", + originY: "center", + overlayX: "end", + overlayY: "center", + panelClass: ["bit-tooltip-left-center"], + }, + // ... below trigger + { + id: "below-center", + offsetY: ORIGIN_OFFSET_PX, + originX: "center", + originY: "bottom", + overlayX: "center", + overlayY: "top", + panelClass: ["bit-tooltip-below-center"], + }, + // ... above trigger + { + id: "above-center", + offsetY: -ORIGIN_OFFSET_PX, + originX: "center", + originY: "top", + overlayX: "center", + overlayY: "bottom", + panelClass: ["bit-tooltip-above-center"], + }, +]; diff --git a/libs/components/src/tooltip/tooltip.component.css b/libs/components/src/tooltip/tooltip.component.css new file mode 100644 index 00000000000..4abb9908f25 --- /dev/null +++ b/libs/components/src/tooltip/tooltip.component.css @@ -0,0 +1,132 @@ +:root { + --tooltip-shadow: rgb(0 0 0 / 0.1); +} + +.cdk-overlay-pane:has(.bit-tooltip[data-visible="false"]) { + pointer-events: none; +} + +.bit-tooltip-container { + position: relative; + max-width: 12rem; + opacity: 0; + width: max-content; + box-shadow: + 0 4px 6px -1px var(--tooltip-shadow), + 0 2px 4px -2px var(--tooltip-shadow); + border-radius: 0.75rem; + transition: + transform 100ms ease-in-out, + opacity 100ms ease-in-out; + transform: scale(0.95); + z-index: 1; + + &::before, + &::after { + content: ""; + position: absolute; + width: 1rem; + height: 1rem; + z-index: 1; + rotate: 45deg; + border-radius: 3px; + } + + &::before { + background: linear-gradient(135deg, transparent 50%, rgb(var(--color-text-main)) 50%); + z-index: -1; + } + + &::after { + background: rgb(var(--color-text-main)); + z-index: -1; + } + + &[data-visible="true"] { + opacity: 1; + transform: scale(1); + z-index: 1000; + } + + .bit-tooltip-above-center &, + .bit-tooltip-below-center & { + &::before, + &::after { + inset-inline-start: 50%; + transform: translateX(-50%); + transform-origin: left; + } + } + + .bit-tooltip-above-center & { + &::after { + filter: drop-shadow(0 3px 5px var(--tooltip-shadow)) + drop-shadow(0 1px 3px var(--tooltip-shadow)); + } + + &::before, + &::after { + inset-block-end: -0.25rem; + } + } + + .bit-tooltip-below-center & { + &::after { + display: none; + } + + &::after, + &::before { + inset-block-start: -0.25rem; + rotate: -135deg; + } + } + + .bit-tooltip-left-center &, + .bit-tooltip-right-center & { + &::after, + &::before { + inset-block-start: 50%; + transform: translateY(-50%); + transform-origin: top; + } + } + + .bit-tooltip-left-center & { + &::after { + filter: drop-shadow(-3px 1px 3px var(--tooltip-shadow)) + drop-shadow(-1px 2px 3px var(--tooltip-shadow)); + } + + &::after, + &::before { + inset-inline-end: -0.25rem; + rotate: -45deg; + } + } + + .bit-tooltip-right-center & { + &::after { + filter: drop-shadow(2px -4px 2px var(--tooltip-shadow)) + drop-shadow(0 -1px 3px var(--tooltip-shadow)); + } + + &::after, + &::before { + inset-inline-start: -0.25rem; + rotate: 135deg; + } + } +} + +.bit-tooltip { + width: max-content; + max-width: 12rem; + background-color: rgb(var(--color-text-main)); + color: rgb(var(--color-text-contrast)); + padding: 0.5rem 0.75rem; + border-radius: 0.75rem; + font-size: 0.875rem; + line-height: 1.25rem; + z-index: 2; +} diff --git a/libs/components/src/tooltip/tooltip.component.html b/libs/components/src/tooltip/tooltip.component.html new file mode 100644 index 00000000000..c75cd5fb0d4 --- /dev/null +++ b/libs/components/src/tooltip/tooltip.component.html @@ -0,0 +1,10 @@ + +
+ +
diff --git a/libs/components/src/tooltip/tooltip.component.ts b/libs/components/src/tooltip/tooltip.component.ts new file mode 100644 index 00000000000..6b240507311 --- /dev/null +++ b/libs/components/src/tooltip/tooltip.component.ts @@ -0,0 +1,36 @@ +import { CommonModule } from "@angular/common"; +import { + Component, + ElementRef, + inject, + InjectionToken, + Signal, + TemplateRef, + viewChild, +} from "@angular/core"; + +import { TooltipPosition } from "./tooltip-positions"; + +type TooltipData = { + content: Signal; + isVisible: Signal; + tooltipPosition: Signal; +}; + +export const TOOLTIP_DATA = new InjectionToken("TOOLTIP_DATA"); + +/** + * tooltip component used internally by the tooltip.directive. Not meant to be used explicitly + */ +@Component({ + selector: "bit-tooltip", + templateUrl: "./tooltip.component.html", + imports: [CommonModule], +}) +export class TooltipComponent { + readonly templateRef = viewChild.required(TemplateRef); + + private elementRef = inject(ElementRef); + + readonly tooltipData = inject(TOOLTIP_DATA); +} diff --git a/libs/components/src/tooltip/tooltip.directive.ts b/libs/components/src/tooltip/tooltip.directive.ts new file mode 100644 index 00000000000..153fecfe7bf --- /dev/null +++ b/libs/components/src/tooltip/tooltip.directive.ts @@ -0,0 +1,110 @@ +import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; +import { ComponentPortal } from "@angular/cdk/portal"; +import { + Directive, + ViewContainerRef, + inject, + OnInit, + ElementRef, + Injector, + input, + effect, + signal, +} from "@angular/core"; + +import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions"; +import { TooltipComponent, TOOLTIP_DATA } from "./tooltip.component"; + +/** + * Directive to add a tooltip to any element. The tooltip content is provided via the `bitTooltip` input. + * The position of the tooltip can be set via the `tooltipPosition` input. Default position is "above-center". + */ +@Directive({ + selector: "[bitTooltip]", + host: { + "(mouseenter)": "showTooltip()", + "(mouseleave)": "hideTooltip()", + "(focus)": "showTooltip()", + "(blur)": "hideTooltip()", + }, +}) +export class TooltipDirective implements OnInit { + /** + * The value of this input is forwarded to the tooltip.component to render + */ + readonly bitTooltip = input.required(); + /** + * The value of this input is forwarded to the tooltip.component to set its position explicitly. + * @default "above-center" + */ + readonly tooltipPosition = input("above-center"); + + private isVisible = signal(false); + private overlayRef: OverlayRef | undefined; + private elementRef = inject(ElementRef); + private overlay = inject(Overlay); + private viewContainerRef = inject(ViewContainerRef); + private injector = inject(Injector); + private positionStrategy = this.overlay + .position() + .flexibleConnectedTo(this.elementRef) + .withFlexibleDimensions(false) + .withPush(true); + + private tooltipPortal = new ComponentPortal( + TooltipComponent, + this.viewContainerRef, + Injector.create({ + providers: [ + { + provide: TOOLTIP_DATA, + useValue: { + content: this.bitTooltip, + isVisible: this.isVisible, + tooltipPosition: this.tooltipPosition, + }, + }, + ], + }), + ); + + private showTooltip = () => { + this.isVisible.set(true); + }; + + private hideTooltip = () => { + this.isVisible.set(false); + }; + + private computePositions(tooltipPosition: TooltipPositionIdentifier) { + const chosenPosition = tooltipPositions.find((position) => position.id === tooltipPosition); + + return chosenPosition ? [chosenPosition, ...tooltipPositions] : tooltipPositions; + } + + get defaultPopoverConfig(): OverlayConfig { + return { + hasBackdrop: false, + scrollStrategy: this.overlay.scrollStrategies.reposition(), + }; + } + + ngOnInit() { + this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition())); + + this.overlayRef = this.overlay.create({ + ...this.defaultPopoverConfig, + positionStrategy: this.positionStrategy, + }); + + this.overlayRef.attach(this.tooltipPortal); + + effect( + () => { + this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition())); + this.overlayRef?.updatePosition(); + }, + { injector: this.injector }, + ); + } +} diff --git a/libs/components/src/tooltip/tooltip.mdx b/libs/components/src/tooltip/tooltip.mdx new file mode 100644 index 00000000000..4b6f10d97f8 --- /dev/null +++ b/libs/components/src/tooltip/tooltip.mdx @@ -0,0 +1,31 @@ +import { Meta, Canvas, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs"; + +import * as stories from "./tooltip.stories"; + + + +```ts +import { TooltipDirective } from "@bitwarden/components"; +``` + + +<Description /> + +NOTE: The `TooltipComponent` can't be used on its own. It must be applied via the `TooltipDirective` + +<Primary /> +<Controls /> + +## Stories + +### All available positions + +<Canvas of={stories.AllPositions} /> + +### Used with a long content + +<Canvas of={stories.LongContent} /> + +### On disabled element + +<Canvas of={stories.OnDisabledButton} /> diff --git a/libs/components/src/tooltip/tooltip.spec.ts b/libs/components/src/tooltip/tooltip.spec.ts new file mode 100644 index 00000000000..57e05e4f65f --- /dev/null +++ b/libs/components/src/tooltip/tooltip.spec.ts @@ -0,0 +1,103 @@ +import { + ConnectedOverlayPositionChange, + ConnectionPositionPair, + OverlayConfig, + Overlay, +} from "@angular/cdk/overlay"; +import { ComponentPortal } from "@angular/cdk/portal"; +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { Observable, Subject } from "rxjs"; + +import { TooltipDirective } from "./tooltip.directive"; + +@Component({ + standalone: true, + imports: [TooltipDirective], + template: ` <button [bitTooltip]="tooltipText" type="button">Hover or focus me</button> `, +}) +class TooltipHostComponent { + tooltipText = "Hello Tooltip"; +} + +/** Minimal strategy shape the directive expects */ +interface StrategyLike { + withFlexibleDimensions: (flex: boolean) => StrategyLike; + withPush: (push: boolean) => StrategyLike; + withPositions: (positions: ReadonlyArray<ConnectionPositionPair>) => StrategyLike; + readonly positionChanges: Observable<ConnectedOverlayPositionChange>; +} + +/** Minimal Overlay service shape */ +interface OverlayLike { + position: () => { flexibleConnectedTo: (_: unknown) => StrategyLike }; + create: (_: OverlayConfig) => OverlayRefStub; + scrollStrategies: { reposition: () => unknown }; +} + +interface OverlayRefStub { + attach: (portal: ComponentPortal<unknown>) => unknown; + updatePosition: () => void; +} + +describe("TooltipDirective (visibility only)", () => { + let fixture: ComponentFixture<TooltipHostComponent>; + + beforeEach(() => { + const positionChanges$ = new Subject<ConnectedOverlayPositionChange>(); + + const strategy: StrategyLike = { + withFlexibleDimensions: jest.fn(() => strategy), + withPush: jest.fn(() => strategy), + withPositions: jest.fn(() => strategy), + get positionChanges() { + return positionChanges$.asObservable(); + }, + }; + + const overlayRefStub: OverlayRefStub = { + attach: jest.fn(() => ({})), + updatePosition: jest.fn(), + }; + + const overlayMock: OverlayLike = { + position: () => ({ flexibleConnectedTo: () => strategy }), + create: (_: OverlayConfig) => overlayRefStub, + scrollStrategies: { reposition: () => ({}) }, + }; + + TestBed.configureTestingModule({ + imports: [TooltipHostComponent], + providers: [{ provide: Overlay, useValue: overlayMock as unknown as Overlay }], + }); + + fixture = TestBed.createComponent(TooltipHostComponent); + fixture.detectChanges(); + }); + + function getDirective(): TooltipDirective { + const hostDE = fixture.debugElement.query(By.directive(TooltipDirective)); + return hostDE.injector.get(TooltipDirective); + } + + it("sets isVisible to true on mouseenter", () => { + const button: HTMLButtonElement = fixture.debugElement.query(By.css("button")).nativeElement; + const directive = getDirective(); + + const isVisible = (directive as unknown as { isVisible: () => boolean }).isVisible; + + button.dispatchEvent(new Event("mouseenter")); + expect(isVisible()).toBe(true); + }); + + it("sets isVisible to true on focus", () => { + const button: HTMLButtonElement = fixture.debugElement.query(By.css("button")).nativeElement; + const directive = getDirective(); + + const isVisible = (directive as unknown as { isVisible: () => boolean }).isVisible; + + button.dispatchEvent(new Event("focus")); + expect(isVisible()).toBe(true); + }); +}); diff --git a/libs/components/src/tooltip/tooltip.stories.ts b/libs/components/src/tooltip/tooltip.stories.ts new file mode 100644 index 00000000000..8ea3f52f913 --- /dev/null +++ b/libs/components/src/tooltip/tooltip.stories.ts @@ -0,0 +1,153 @@ +import { signal } from "@angular/core"; +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { getByRole, userEvent } from "@storybook/test"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { ButtonComponent } from "../button"; +import { BitIconButtonComponent } from "../icon-button"; +import { I18nMockService } from "../utils"; + +import { TooltipPosition, TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions"; +import { TOOLTIP_DATA, TooltipComponent } from "./tooltip.component"; +import { TooltipDirective } from "./tooltip.directive"; + +import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet"; + +export default { + title: "Component Library/Tooltip", + component: TooltipDirective, + decorators: [ + moduleMetadata({ + imports: [TooltipDirective, TooltipComponent, BitIconButtonComponent, ButtonComponent], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + loading: "Loading", + }); + }, + }, + { + provide: TOOLTIP_DATA, + useFactory: () => { + // simple fixed demo values for the Default story + return { + content: signal("This is a tooltip"), + isVisible: signal(true), + tooltipPosition: signal<TooltipPositionIdentifier>("above-center"), + }; + }, + }, + ], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?m=auto&node-id=30558-13730&t=4k23PtzCwqDekAZW-1", + }, + }, + argTypes: { + bitTooltip: { + control: "text", + description: "Text content of the tooltip", + }, + tooltipPosition: { + control: "select", + options: tooltipPositions.map((position: TooltipPosition) => position.id), + description: "Position of the tooltip relative to the element", + table: { + type: { + summary: tooltipPositions.map((position: TooltipPosition) => position.id).join(" | "), + }, + defaultValue: { summary: "above-center" }, + }, + }, + }, +} as Meta<TooltipDirective>; + +type Story = StoryObj<TooltipDirective>; + +export const Default: Story = { + args: { + bitTooltip: "This is a tooltip", + tooltipPosition: "above-center", + }, + render: (args) => ({ + props: args, + template: ` + <div class="tw-p-4"> + <button + bitIconButton="bwi-ellipsis-v" + ${formatArgsForCodeSnippet<TooltipDirective>(args)} + > + Button label here + </button> + </div> + `, + }), + play: async (context) => { + const canvasEl = context.canvasElement; + const button = getByRole(canvasEl, "button"); + + await userEvent.hover(button); + }, +}; + +export const AllPositions: Story = { + render: () => ({ + template: ` + <div class="tw-p-16 tw-grid tw-grid-cols-2 tw-gap-8 tw-place-items-center"> + <button + bitIconButton="bwi-angle-up" + bitTooltip="Top tooltip" + tooltipPosition="above-center" + ></button> + <button + bitIconButton="bwi-angle-right" + bitTooltip="Right tooltip" + tooltipPosition="right-center" + ></button> + <button + bitIconButton="bwi-angle-left" + bitTooltip="Left tooltip" + tooltipPosition="left-center" + ></button> + <button + bitIconButton="bwi-angle-down" + bitTooltip="Bottom tooltip" + tooltipPosition="below-center" + ></button> + </div> + `, + }), +}; + +export const LongContent: Story = { + render: () => ({ + template: ` + <div class="tw-p-16 tw-flex tw-items-center tw-justify-center"> + <button + bitIconButton="bwi-ellipsis-v" + bitTooltip="This is a very long tooltip that will wrap to multiple lines to demonstrate how the tooltip handles long content. This is not recommended for usability." + ></button> + </div> + `, + }), +}; + +export const OnDisabledButton: Story = { + render: () => ({ + template: ` + <div class="tw-p-16 tw-flex tw-items-center tw-justify-center"> + <button + bitIconButton="bwi-ellipsis-v" + bitTooltip="Tooltip on disabled button" + [disabled]="true" + ></button> + </div> + `, + }), +}; diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index ec29bc522eb..1e0a6f438f0 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -5,6 +5,7 @@ @import "./popover/popover.component.css"; @import "./toast/toast.tokens.css"; @import "./toast/toastr.css"; +@import "./tooltip/tooltip.component.css"; @import "./search/search.component.css"; @tailwind base; diff --git a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts index 70c783df52a..14a16211deb 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts @@ -64,12 +64,13 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { private async parseEncrypted( results: BitwardenEncryptedIndividualJsonExport | BitwardenEncryptedOrgJsonExport, ) { + const account = await firstValueFrom(this.accountService.activeAccount$); + if (results.encKeyValidation_DO_NOT_EDIT != null) { - let keyForDecryption: SymmetricCryptoKey = await this.keyService.getOrgKey( - this.organizationId, - ); + const orgKeys = await firstValueFrom(this.keyService.orgKeys$(account.id)); + let keyForDecryption: SymmetricCryptoKey = orgKeys?.[this.organizationId]; if (keyForDecryption == null) { - keyForDecryption = await this.keyService.getUserKey(); + keyForDecryption = await firstValueFrom(this.keyService.userKey$(account.id)); } const encKeyValidation = new EncString(results.encKeyValidation_DO_NOT_EDIT); try { @@ -113,10 +114,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { }); } - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - const view = await this.cipherService.decrypt(cipher, activeUserId); + const view = await this.cipherService.decrypt(cipher, account.id); this.cleanupCipher(view); this.result.ciphers.push(view); } diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts index 7812cce2c05..dfdcef51735 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts @@ -1,12 +1,17 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { emptyGuid, OrganizationId } from "@bitwarden/common/types/guid"; +import { OrgKey, UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { newGuid } from "@bitwarden/guid"; import { KdfType, KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; import { emptyAccountEncrypted } from "../spec-data/bitwarden-json/account-encrypted.json"; import { emptyUnencryptedExport } from "../spec-data/bitwarden-json/unencrypted.json"; @@ -35,6 +40,36 @@ describe("BitwardenPasswordProtectedImporter", () => { pinService = mock<PinServiceAbstraction>(); accountService = mock<AccountService>(); + accountService.activeAccount$ = of({ + id: newGuid() as UserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + }); + + const mockOrgId = emptyGuid as OrganizationId; + /* + The key values below are never read, empty objects are cast as types for compilation type checking only. + Tests specific to key contents are in key-service.spec.ts + */ + const mockOrgKey = {} as unknown as OrgKey; + const mockUserKey = {} as unknown as UserKey; + + keyService.orgKeys$.mockImplementation(() => + of({ [mockOrgId]: mockOrgKey } as Record<OrganizationId, OrgKey>), + ); + keyService.userKey$.mockImplementation(() => of(mockUserKey)); + (keyService as any).activeUserOrgKeys$ = of({ + [mockOrgId]: mockOrgKey, + } as Record<OrganizationId, OrgKey>); + + /* + Crypto isn’t under test here; keys are just placeholders. + Decryption methods are stubbed to always return empty CipherView or string allowing OK import flow. + */ + cipherService.decrypt.mockResolvedValue({} as any); + encryptService.decryptString.mockResolvedValue("ok"); + importer = new BitwardenPasswordProtectedImporter( keyService, encryptService, @@ -62,6 +97,24 @@ describe("BitwardenPasswordProtectedImporter", () => { jest.spyOn(BitwardenJsonImporter.prototype, "parse"); }); + beforeEach(() => { + accountService.activeAccount$ = of({ + id: newGuid() as UserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + }); + importer = new BitwardenPasswordProtectedImporter( + keyService, + encryptService, + i18nService, + cipherService, + pinService, + accountService, + promptForPassword_callback, + ); + }); + it("Should call BitwardenJsonImporter", async () => { expect((await importer.parse(emptyAccountEncrypted)).success).toEqual(true); expect(BitwardenJsonImporter.prototype.parse).toHaveBeenCalledWith(emptyAccountEncrypted); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts index 3884dde4b18..53952938aa8 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts @@ -15,6 +15,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { CipherWithIdExport, CollectionWithIdExport } from "@bitwarden/common/models/export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -22,6 +23,7 @@ import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { newGuid } from "@bitwarden/guid"; import { KdfConfigService, KeyService } from "@bitwarden/key-management"; import { @@ -112,7 +114,7 @@ export class OrganizationVaultExportService type: "text/plain", data: onlyManagedCollections ? await this.getEncryptedManagedExport(userId, organizationId) - : await this.getOrganizationEncryptedExport(organizationId), + : await this.getOrganizationEncryptedExport(userId, organizationId), fileName: ExportHelper.getFileName("org", "encrypted_json"), } as ExportedVaultAsString; } @@ -184,7 +186,10 @@ export class OrganizationVaultExportService return this.buildJsonExport(decCollections, decCiphers); } - private async getOrganizationEncryptedExport(organizationId: OrganizationId): Promise<string> { + private async getOrganizationEncryptedExport( + userId: UserId, + organizationId: OrganizationId, + ): Promise<string> { const collections: Collection[] = []; const ciphers: Cipher[] = []; @@ -215,7 +220,7 @@ export class OrganizationVaultExportService } }); } - return this.BuildEncryptedExport(organizationId, collections, ciphers); + return this.BuildEncryptedExport(userId, organizationId, collections, ciphers); } private async getDecryptedManagedExport( @@ -295,16 +300,21 @@ export class OrganizationVaultExportService !this.restrictedItemTypesService.isCipherRestricted(f, restrictions), ); - return this.BuildEncryptedExport(organizationId, encCollections, encCiphers); + return this.BuildEncryptedExport(activeUserId, organizationId, encCollections, encCiphers); } private async BuildEncryptedExport( + activeUserId: UserId, organizationId: OrganizationId, collections: Collection[], ciphers: Cipher[], ): Promise<string> { - const orgKey = await this.keyService.getOrgKey(organizationId); - const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), orgKey); + const orgKeys = await firstValueFrom(this.keyService.orgKeys$(activeUserId)); + const keyForEncryption: SymmetricCryptoKey = orgKeys?.[organizationId]; + if (keyForEncryption == null) { + throw new Error("No encryption key found for organization"); + } + const encKeyValidation = await this.encryptService.encryptString(newGuid(), keyForEncryption); const jsonDoc: BitwardenEncryptedOrgJsonExport = { encrypted: true,