1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 21:20:27 +00:00

Merge branch 'main' into billing/PM-24996/implement-upgrade-from-free-dialog

This commit is contained in:
Stephon Brown
2025-10-01 18:46:23 -04:00
171 changed files with 2964 additions and 5472 deletions

1
.github/CODEOWNERS vendored
View File

@@ -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

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ Thumbs.db
.settings/
*.sublime-workspace
.claude
.serena
# Visual Studio Code
.vscode/*

View File

@@ -550,8 +550,13 @@
"resetSearch": {
"message": "Reset search"
},
"archive": {
"message": "Archive"
"archiveNoun": {
"message": "Archive",
"description": "Noun"
},
"archiveVerb": {
"message": "Archive",
"description": "Verb"
},
"unarchive": {
"message": "Unarchive"

View File

@@ -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);
});
});
});

View File

@@ -0,0 +1,13 @@
import {
DefaultNewDeviceVerificationComponentService,
NewDeviceVerificationComponentService,
} from "@bitwarden/auth/angular";
export class ExtensionNewDeviceVerificationComponentService
extends DefaultNewDeviceVerificationComponentService
implements NewDeviceVerificationComponentService
{
showBackButton() {
return false;
}
}

View File

@@ -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({

View File

@@ -40,7 +40,7 @@
</ng-container>
@if (canArchive$ | async) {
<button type="button" bitMenuItem (click)="archive()" *ngIf="canArchive$ | async">
{{ "archive" | i18n }}
{{ "archiveVerb" | i18n }}
</button>
}
</bit-menu>

View File

@@ -37,7 +37,7 @@
@if (userCanArchive() || showArchiveFilter()) {
<bit-item>
<a bit-item-content routerLink="/archive">
{{ "archive" | i18n }}
{{ "archiveNoun" | i18n }}
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>

View File

@@ -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",

View File

@@ -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 = [

View File

@@ -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<String, ()> {
/// # 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<String> {
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<u16>, keyboard_shortcut: Vec<String>) -> 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<u16>, keyboard_shortcut: Vec<String>) -> Result<()> {
windowing::type_input(input, keyboard_shortcut)
}

View File

@@ -1,10 +1,7 @@
pub fn get_foreground_window_title() -> std::result::Result<String, ()> {
pub fn get_foreground_window_title() -> anyhow::Result<String> {
todo!("Bitwarden does not yet support Linux autotype");
}
pub fn type_input(
_input: Vec<u16>,
_keyboard_shortcut: Vec<String>,
) -> std::result::Result<(), ()> {
pub fn type_input(_input: Vec<u16>, _keyboard_shortcut: Vec<String>) -> anyhow::Result<()> {
todo!("Bitwarden does not yet support Linux autotype");
}

View File

@@ -1,10 +1,7 @@
pub fn get_foreground_window_title() -> std::result::Result<String, ()> {
pub fn get_foreground_window_title() -> anyhow::Result<String> {
todo!("Bitwarden does not yet support macOS autotype");
}
pub fn type_input(
_input: Vec<u16>,
_keyboard_shortcut: Vec<String>,
) -> std::result::Result<(), ()> {
pub fn type_input(_input: Vec<u16>, _keyboard_shortcut: Vec<String>) -> anyhow::Result<()> {
todo!("Bitwarden does not yet support macOS autotype");
}

View File

@@ -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<String, ()> {
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<String> {
// 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<usize> {
// 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<String> {
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<u16> = 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<u16>, keyboard_shortcut: Vec<String>) -> Result<(), ()> {
pub fn type_input(input: Vec<u16>, keyboard_shortcut: Vec<String>) -> Result<()> {
const TAB_KEY: u8 = 9;
let mut keyboard_inputs: Vec<INPUT> = Vec::new();
// the length of this vec is always shortcut keys to release + (2x length of input chars)
let mut keyboard_inputs: Vec<INPUT> =
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<u16>, keyboard_shortcut: Vec<String>) -> 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<INPUT, ()> {
fn convert_shortcut_key_to_up_input(key: String) -> Result<INPUT> {
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<INPUT, ()> {
/// 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<u16, ()> {
fn get_alphabetic_hotkey(letter: String) -> Result<u16> {
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<u16, ()> {
// 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<HWND, ()> {
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<usize, ()> {
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<Option<String>, ()> {
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<u16> = 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<INPUT>) -> Result<(), ()> {
fn send_input(inputs: Vec<INPUT>) -> Result<()> {
let insert_count = unsafe { SendInput(&inputs, std::mem::size_of::<INPUT>() 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();
}
}

View File

@@ -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<InternalPolicyService>;
let configService: MockProxy<ConfigService>;
let mockAccountSubject: BehaviorSubject<{ id: UserId } | null>;
let mockAccountSubject: BehaviorSubject<Account | null>;
let mockFeatureFlagSubject: BehaviorSubject<boolean>;
let mockAuthStatusSubject: BehaviorSubject<AuthenticationStatus>;
let mockPolicyAppliesSubject: BehaviorSubject<boolean>;
let mockPoliciesSubject: BehaviorSubject<Policy[]>;
const mockUserId = "user-123" as UserId;
@@ -36,7 +38,7 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => {
mockAuthStatusSubject = new BehaviorSubject<AuthenticationStatus>(
AuthenticationStatus.Unlocked,
);
mockPolicyAppliesSubject = new BehaviorSubject<boolean>(false);
mockPoliciesSubject = new BehaviorSubject<Policy[]>([]);
accountService = mock<AccountService>();
authService = mock<AuthService>();
@@ -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);
});
});
});

View File

@@ -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))));
}),

View File

@@ -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(

View File

@@ -4114,8 +4114,13 @@
"editShortcut": {
"message": "Edit shortcut"
},
"archive": {
"message": "Archive"
"archiveNoun": {
"message": "Archive",
"description": "Noun"
},
"archiveVerb": {
"message": "Archive",
"description": "Verb"
},
"unarchive": {
"message": "Unarchive"

View File

@@ -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) {

View File

@@ -71,10 +71,9 @@
>
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item>
<ng-container *ngIf="(showPaymentAndHistory$ | async) && (organizationIsUnmanaged$ | async)">
@let paymentDetailsPageData = paymentDetailsPageData$ | async;
<bit-nav-item
[text]="paymentDetailsPageData.textKey | i18n"
[route]="paymentDetailsPageData.route"
[text]="'paymentDetails' | i18n"
route="billing/payment-details"
></bit-nav-item>
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
</ng-container>

View File

@@ -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<boolean>;
protected paymentDetailsPageData$: Observable<{
route: string;
textKey: string;
}>;
protected subscriber$: Observable<NonIndividualSubscriber>;
protected getTaxIdWarning$: () => Observable<TaxIdWarningType | null>;
@@ -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",

View File

@@ -975,12 +975,11 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
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 },
});
}
}

View File

@@ -1,104 +0,0 @@
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<form
#form
[formGroup]="formGroup"
[appApiAction]="formPromise"
(ngSubmit)="submit()"
*ngIf="!loading"
>
<div class="tw-container tw-mb-3">
<div class="tw-mb-6">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "billingPlanLabel" | i18n }}</h2>
<div class="tw-mb-1 tw-items-center" *ngIf="annualPlan !== null">
<label class="tw- tw-block tw-text-main" for="annual">
<input
class="tw-size-4 tw-align-middle"
id="annual"
name="cadence"
type="radio"
[value]="annualCadence"
formControlName="cadence"
/>
{{ "annual" | i18n }} -
{{ getPriceFor(annualCadence) | currency: "$" }}
/{{ "yr" | i18n }}
</label>
</div>
<div class="tw-mb-1 tw-items-center" *ngIf="monthlyPlan !== null">
<label class="tw- tw-block tw-text-main" for="monthly">
<input
class="tw-size-4 tw-align-middle"
id="monthly"
name="cadence"
type="radio"
[value]="monthlyCadence"
formControlName="cadence"
/>
{{ "monthly" | i18n }} -
{{ getPriceFor(monthlyCadence) | currency: "$" }}
/{{ "monthAbbr" | i18n }}
</label>
</div>
</div>
<div class="tw-mb-4">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2>
<app-payment [showAccountCredit]="false"></app-payment>
<app-manage-tax-information
[showTaxIdField]="showTaxIdField"
(taxInformationChanged)="onTaxInformationChanged()"
></app-manage-tax-information>
@if (trialLength === 0) {
@let priceLabel =
subscriptionProduct === SubscriptionProduct.PasswordManager
? "passwordManagerPlanPrice"
: "secretsManagerPlanPrice";
<div id="price" class="tw-my-4">
<div class="tw-text-muted tw-text-base">
{{ priceLabel | i18n }}: {{ getPriceFor(formGroup.value.cadence) | currency: "USD $" }}
<div>
{{ "estimatedTax" | i18n }}:
@if (fetchingTaxAmount) {
<ng-container *ngTemplateOutlet="loadingSpinner" />
} @else {
{{ taxAmount | currency: "USD $" }}
}
</div>
</div>
<hr class="tw-my-1 tw-grid tw-grid-cols-3 tw-ml-0" />
<p class="tw-text-lg">
<strong>{{ "total" | i18n }}: </strong>
@if (fetchingTaxAmount) {
<ng-container *ngTemplateOutlet="loadingSpinner" />
} @else {
{{ total | currency: "USD $" }}/{{ interval | i18n }}
}
</p>
</div>
}
</div>
<div class="tw-flex tw-space-x-2">
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
{{ (trialLength > 0 ? "startTrial" : "submit") | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" (click)="stepBack()">Back</button>
</div>
</div>
</form>
<ng-template #loadingSpinner>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-template>

View File

@@ -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<ProductTierType, ProductTierType.Free>;
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<OrganizationCreatedEvent>();
loading = true;
fetchingTaxAmount = false;
annualCadence = SubscriptionCadence.Annual;
monthlyCadence = SubscriptionCadence.Monthly;
formGroup = this.formBuilder.group({
cadence: [SubscriptionCadence.Annual, Validators.required],
});
formPromise: Promise<string>;
applicablePlans: PlanResponse[];
annualPlan?: PlanResponse;
monthlyPlan?: PlanResponse;
taxAmount = 0;
private destroy$ = new Subject<void>();
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<void> {
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<void> {
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<string> {
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<number> => {
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";
}
}

View File

@@ -1,3 +1,4 @@
export * from "./organization-billing.client";
export * from "./subscriber-billing.client";
export * from "./tax.client";
export * from "./account-billing.client";

View File

@@ -82,6 +82,24 @@ export class SubscriberBillingClient {
return data ? new MaskedPaymentMethodResponse(data).value : null;
};
restartSubscription = async (
subscriber: BitwardenSubscriber,
paymentMethod: TokenizedPaymentMethod,
billingAddress: BillingAddress,
): Promise<void> => {
const path = `${this.getEndpoint(subscriber)}/subscription/restart`;
await this.apiService.send(
"POST",
path,
{
paymentMethod,
billingAddress,
},
true,
false,
);
};
updateBillingAddress = async (
subscriber: BitwardenSubscriber,
billingAddress: BillingAddress,

View File

@@ -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<TaxAmounts> => {
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<TaxAmounts> => {
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<TaxAmounts> => {
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<TaxAmounts> => {
const json = await this.apiService.send(
"POST",
`/billing/tax/premium/subscriptions/purchase`,
{
additionalStorage,
billingAddress,
},
true,
true,
);
return new TaxAmountResponse(json);
};
}

View File

@@ -1,2 +1 @@
export { OrganizationPlansComponent } from "./organizations";
export { TaxInfoComponent } from "./shared";

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<View | null>(null);
private load$: Observable<View> = 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<View> = 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) => {

View File

@@ -70,7 +70,7 @@
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="addOnFormGroup" [bitSubmit]="submitPayment">
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
@@ -93,15 +93,25 @@
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB &times;
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
{{ storageGBPrice | currency: "$" }} =
{{ additionalStorageCost | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<app-payment [showBankAccount]="false"></app-payment>
<app-tax-info (taxInformationChanged)="onTaxInformationChanged()"></app-tax-info>
<div class="tw-mb-4">
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
[showBankAccount]="false"
>
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>

View File

@@ -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<boolean>;
protected addOnFormGroup = new FormGroup({
protected formGroup = new FormGroup({
additionalStorage: new FormControl<number>(0, [Validators.min(0), Validators.max(99)]),
});
protected licenseFormGroup = new FormGroup({
file: new FormControl<File>(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<boolean> =>
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<void> => {
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<void> => {
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<void> {
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;
}
}

View File

@@ -3,10 +3,7 @@
<bit-tab-link [route]="(hasPremium$ | async) ? 'user-subscription' : 'premium'">{{
"subscription" | i18n
}}</bit-tab-link>
@let paymentMethodPageData = paymentDetailsPageData$ | async;
<bit-tab-link [route]="paymentMethodPageData.route">{{
paymentMethodPageData.textKey | i18n
}}</bit-tab-link>
<bit-tab-link route="payment-details">{{ "paymentDetails" | i18n }}</bit-tab-link>
<bit-tab-link route="billing-history">{{ "billingHistory" | i18n }}</bit-tab-link>
</bit-tab-nav-bar>
</app-header>

View File

@@ -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<boolean>;
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() {

View File

@@ -328,24 +328,60 @@
*ngIf="formGroup.value.productTier !== productTypes.Free || isSubscriptionCanceled"
>
<h2 bitTypography="h4">{{ "paymentMethod" | i18n }}</h2>
<p
*ngIf="
!showPayment && (paymentSource || billing?.paymentSource) && !isSubscriptionCanceled
"
>
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
{{ paymentSource?.description }}
<span class="tw-ml-2 tw-text-primary-600 tw-cursor-pointer" (click)="toggleShowPayment()">
{{ "changePaymentMethod" | i18n }}
</span>
<p *ngIf="!showPayment && !!paymentMethod && !isSubscriptionCanceled">
@switch (paymentMethod.type) {
@case ("bankAccount") {
<i class="bwi bwi-fw bwi-billing"></i>
{{ paymentMethod.bankName }}, *{{ paymentMethod.last4 }}
@if (paymentMethod.hostedVerificationUrl) {
<span>- {{ "unverified" | i18n }}</span>
}
<span
class="tw-ml-2 tw-text-primary-600 tw-cursor-pointer"
(click)="toggleShowPayment()"
>
{{ "changePaymentMethod" | i18n }}
</span>
}
@case ("card") {
<p class="tw-flex tw-items-center tw-gap-2">
@let cardBrandIcon = getCardBrandIcon();
@if (cardBrandIcon !== null) {
<i class="bwi bwi-fw credit-card-icon {{ cardBrandIcon }}"></i>
} @else {
<i class="bwi bwi-fw bwi-credit-card"></i>
}
{{ paymentMethod.brand | titlecase }}, *{{ paymentMethod.last4 }},
{{ paymentMethod.expiration }}
<span
class="tw-ml-2 tw-text-primary-600 tw-cursor-pointer"
(click)="toggleShowPayment()"
>
{{ "changePaymentMethod" | i18n }}
</span>
</p>
}
@case ("payPal") {
<i class="bwi bwi-fw bwi-paypal tw-text-primary-600"></i>
{{ paymentMethod.email }}
<span
class="tw-ml-2 tw-text-primary-600 tw-cursor-pointer"
(click)="toggleShowPayment()"
>
{{ "changePaymentMethod" | i18n }}
</span>
}
}
<a></a>
</p>
<ng-container *ngIf="canUpdatePaymentInformation()">
<app-payment [showAccountCredit]="false" />
<app-manage-tax-information
[startWith]="taxInformation"
(taxInformationChanged)="taxInformationChanged($event)"
></app-manage-tax-information>
<app-enter-payment-method [group]="billingFormGroup.controls.paymentMethod">
</app-enter-payment-method>
<app-enter-billing-address
[group]="billingFormGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId }"
>
</app-enter-billing-address>
</ng-container>
<div class="tw-mt-4">
<p class="tw-text-lg tw-mb-1">

View File

@@ -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<PlanResponse>;
isSubscriptionCanceled: boolean = false;
secretsManagerTotal: number;
private destroy$ = new Subject<void>();
paymentMethod: MaskedPaymentMethod | null;
billingAddress: BillingAddress | null;
protected taxInformation: TaxInformation;
private destroy$ = new Subject<void>();
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<void> {
@@ -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<string> => {
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<void> {
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);
}

View File

@@ -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,

View File

@@ -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 {}

View File

@@ -404,17 +404,16 @@
<p class="tw-text-muted tw-italic tw-mb-3 tw-block" bitTypography="body2">
{{ paymentDesc }}
</p>
<app-payment
*ngIf="createOrganization || upgradeRequiresPaymentMethod"
[showAccountCredit]="false"
>
</app-payment>
<app-manage-tax-information
@if (createOrganization || upgradeRequiresPaymentMethod) {
<app-enter-payment-method [group]="billingFormGroup.controls.paymentMethod">
</app-enter-payment-method>
}
<app-enter-billing-address
[group]="billingFormGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: showTaxIdField }"
class="tw-my-4"
[showTaxIdField]="showTaxIdField"
[startWith]="taxInformation"
(taxInformationChanged)="onTaxInformationChanged($event)"
/>
>
</app-enter-billing-address>
<div id="price" class="tw-my-4">
<div class="tw-text-muted tw-text-base">
{{ "passwordManagerPlanPrice" | i18n }}: {{ passwordManagerSubtotal | currency: "USD $" }}

View File

@@ -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 = <HTMLInputElement>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<void> {
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

View File

@@ -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<View> = 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<View> = 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,
) {}

View File

@@ -1,48 +0,0 @@
<app-header></app-header>
<bit-container>
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="!loading">
<!-- Account Credit -->
<bit-section>
<h2 bitTypography="h2">
{{ accountCreditHeaderText }}
</h2>
<p class="tw-text-lg tw-font-bold">{{ Math.abs(accountCredit) | currency: "$" }}</p>
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
<button type="button" bitButton buttonType="secondary" [bitAction]="addAccountCredit">
{{ "addCredit" | i18n }}
</button>
</bit-section>
<!-- Payment Method -->
<bit-section>
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
<p *ngIf="!paymentSource" bitTypography="body1">{{ "noPaymentMethod" | i18n }}</p>
<ng-container *ngIf="paymentSource">
<app-verify-bank-account
*ngIf="paymentSource.needsVerification"
[onSubmit]="verifyBankAccount"
(submitted)="load()"
>
</app-verify-bank-account>
<p>
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
{{ paymentSource.description }}
<span *ngIf="paymentSource.needsVerification">- {{ "unverified" | i18n }}</span>
</p>
</ng-container>
<button type="button" bitButton buttonType="secondary" [bitAction]="updatePaymentMethod">
{{ updatePaymentSourceButtonText }}
</button>
<p *ngIf="subscriptionIsUnpaid" bitTypography="body1">
{{ "paymentChargedWithUnpaidSubscription" | i18n }}
</p>
</bit-section>
</ng-container>
</bit-container>

View File

@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<boolean> {
const hasBillingAddress = this.taxInformation != null;
if (!hasBillingAddress) {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("billingAddressRequiredToAddCredit"),
});
return false;
}
return true;
}
}

View File

@@ -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<ConfigService>;
let dialogService: MockProxy<DialogService>;
let i18nService: MockProxy<I18nService>;
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
@@ -57,7 +54,6 @@ describe("OrganizationWarningsService", () => {
});
beforeEach(() => {
configService = mock<ConfigService>();
dialogService = mock<DialogService>();
i18nService = mock<I18nService>();
organizationApiService = mock<OrganizationApiServiceAbstraction>();
@@ -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();
},

View File

@@ -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 },
},

View File

@@ -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") {
<p class="tw-flex tw-items-center tw-gap-2">
@let brandIcon = getBrandIconForCard();
@if (brandIcon !== null) {
<i class="bwi bwi-fw credit-card-icon {{ brandIcon }}"></i>
@let cardBrandIcon = getCardBrandIcon();
@if (cardBrandIcon !== null) {
<i class="bwi bwi-fw credit-card-icon {{ cardBrandIcon }}"></i>
} @else {
<i class="bwi bwi-fw bwi-credit-card"></i>
}
@@ -74,16 +74,6 @@ export class DisplayPaymentMethodComponent {
@Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null;
@Output() updated = new EventEmitter<MaskedPaymentMethod>();
protected availableCardIcons: Record<string, string> = {
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<void> => {
@@ -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);
}

View File

@@ -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,

View File

@@ -24,6 +24,17 @@ export interface BillingAddressControls {
export type BillingAddressFormGroup = FormGroup<ControlsOf<BillingAddressControls>>;
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 =
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "address1" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.line1"
autocomplete="address-line1"
data-testid="address-line1"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "address2" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.line2"
autocomplete="address-line2"
data-testid="address-line2"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "cityTown" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.city"
autocomplete="address-level2"
data-testid="city"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.state"
autocomplete="address-level1"
data-testid="state"
/>
</bit-form-field>
</div>
@if (scenario.type === "update") {
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "address1" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.line1"
autocomplete="address-line1"
data-testid="address-line1"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "address2" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.line2"
autocomplete="address-line2"
data-testid="address-line2"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "cityTown" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.city"
autocomplete="address-level2"
data-testid="city"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.state"
autocomplete="address-level1"
data-testid="state"
/>
</bit-form-field>
</div>
}
@if (supportsTaxId$ | async) {
<div class="tw-col-span-12">
<bit-form-field [disableMargin]="true">
@@ -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;
}

View File

@@ -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<{
<button
[bitPopoverTriggerFor]="cardSecurityCodePopover"
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-p-0"
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-pr-1"
[position]="'above-end'"
>
<i class="bwi bwi-question-circle tw-text-lg" aria-hidden="true"></i>
@@ -310,7 +311,7 @@ export class EnterPaymentMethodComponent implements OnInit {
select = (paymentMethod: PaymentMethodOption) =>
this.group.controls.type.patchValue(paymentMethod);
tokenize = async (): Promise<TokenizedPaymentMethod> => {
tokenize = async (): Promise<TokenizedPaymentMethod | null> => {
const exchange = async (paymentMethod: TokenizablePaymentMethod) => {
switch (paymentMethod) {
case "bankAccount": {
@@ -351,13 +352,37 @@ export class EnterPaymentMethodComponent implements OnInit {
const token = await exchange(this.selected);
return { type: this.selected, token };
} catch (error: unknown) {
this.logService.error(error);
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("problemSubmittingPaymentMethod"),
});
throw error;
if (error) {
this.logService.error(error);
switch (this.selected) {
case "card": {
if (
typeof error === "object" &&
"message" in error &&
typeof error.message === "string"
) {
this.toastService.showToast({
variant: "error",
title: "",
message: error.message,
});
}
return null;
}
case "payPal": {
if (typeof error === "string" && error === "No payment method is available.") {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("clickPayWithPayPal"),
});
return null;
}
}
}
throw error;
}
return null;
}
};

View File

@@ -6,6 +6,7 @@ export * from "./display-payment-method.component";
export * from "./edit-billing-address-dialog.component";
export * from "./enter-billing-address.component";
export * from "./enter-payment-method.component";
export * from "./payment-label.component";
export * from "./require-payment-method-dialog.component";
export * from "./submit-payment-method-dialog.component";
export * from "./verify-bank-account.component";

View File

@@ -13,7 +13,21 @@ import { SharedModule } from "../../../shared";
*/
@Component({
selector: "app-payment-label",
templateUrl: "./payment-label.component.html",
template: `
<ng-template #defaultContent>
<ng-content></ng-content>
</ng-template>
<div class="tw-relative tw-mt-2">
<bit-label
[attr.for]="for"
class="tw-absolute tw-bg-background tw-px-1 tw-text-sm tw-text-muted -tw-top-2.5 tw-left-3 tw-mb-0 tw-max-w-full tw-pointer-events-auto"
>
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
<span class="tw-text-xs tw-font-normal">({{ "required" | i18n }})</span>
</bit-label>
</div>
`,
imports: [FormFieldModule, SharedModule],
})
export class PaymentLabelComponent {

View File

@@ -37,6 +37,10 @@ export abstract class SubmitPaymentMethodDialogComponent {
}
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
if (!paymentMethod) {
return;
}
const billingAddress =
this.formGroup.value.type !== "payPal"
? this.formGroup.controls.billingAddress.getRawValue()

View File

@@ -21,6 +21,24 @@ export const StripeCardBrands = {
export type StripeCardBrand = (typeof StripeCardBrands)[keyof typeof StripeCardBrands];
export const cardBrandIcons: Record<string, string> = {
amex: "card-amex",
diners: "card-diners-club",
discover: "card-discover",
jcb: "card-jcb",
mastercard: "card-mastercard",
unionpay: "card-unionpay",
visa: "card-visa",
};
export const getCardBrandIcon = (paymentMethod: MaskedPaymentMethod | null): string | null => {
if (paymentMethod?.type !== "card") {
return null;
}
return paymentMethod.brand in cardBrandIcons ? cardBrandIcons[paymentMethod.brand] : null;
};
type MaskedBankAccount = {
type: BankAccountPaymentMethod;
bankName: string;

View File

@@ -1,3 +1,5 @@
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
export const TokenizablePaymentMethods = {
bankAccount: "bankAccount",
card: "card",
@@ -16,6 +18,34 @@ export const isTokenizablePaymentMethod = (value: string): value is TokenizableP
return valid.includes(value);
};
export const tokenizablePaymentMethodFromLegacyEnum = (
legacyEnum: PaymentMethodType,
): TokenizablePaymentMethod | null => {
switch (legacyEnum) {
case PaymentMethodType.BankAccount:
return "bankAccount";
case PaymentMethodType.Card:
return "card";
case PaymentMethodType.PayPal:
return "payPal";
default:
return null;
}
};
export const tokenizablePaymentMethodToLegacyEnum = (
paymentMethod: TokenizablePaymentMethod,
): PaymentMethodType => {
switch (paymentMethod) {
case "bankAccount":
return PaymentMethodType.BankAccount;
case "card":
return PaymentMethodType.Card;
case "payPal":
return PaymentMethodType.PayPal;
}
};
export type TokenizedPaymentMethod = {
type: TokenizablePaymentMethod;
token: string;

View File

@@ -19,12 +19,6 @@ interface EnterpriseOrgStatus {
@Injectable({ providedIn: "root" })
export class FreeFamiliesPolicyService {
protected enterpriseOrgStatus: EnterpriseOrgStatus = {
isFreeFamilyPolicyEnabled: false,
belongToOneEnterpriseOrgs: false,
belongToMultipleEnterpriseOrgs: false,
};
constructor(
private policyService: PolicyService,
private organizationService: OrganizationService,
@@ -104,9 +98,11 @@ export class FreeFamiliesPolicyService {
if (!orgStatus) {
return false;
}
const { belongToOneEnterpriseOrgs, isFreeFamilyPolicyEnabled } = orgStatus;
const { isFreeFamilyPolicyEnabled } = orgStatus;
const hasSponsorshipOrgs = organizations.some((org) => org.canManageSponsorships);
return hasSponsorshipOrgs && !(belongToOneEnterpriseOrgs && isFreeFamilyPolicyEnabled);
// Hide if ANY organization has the policy enabled
return hasSponsorshipOrgs && !isFreeFamilyPolicyEnabled;
}
checkEnterpriseOrganizationsAndFetchPolicy(): Observable<EnterpriseOrgStatus> {
@@ -122,16 +118,12 @@ export class FreeFamiliesPolicyService {
const { belongToOneEnterpriseOrgs, belongToMultipleEnterpriseOrgs } =
this.evaluateEnterpriseOrganizations(organizations);
if (!belongToOneEnterpriseOrgs) {
return of({
isFreeFamilyPolicyEnabled: false,
belongToOneEnterpriseOrgs,
belongToMultipleEnterpriseOrgs,
});
}
// Get all enterprise organization IDs
const enterpriseOrgIds = organizations
.filter((org) => org.canManageSponsorships)
.map((org) => org.id);
const organizationId = this.getOrganizationIdForOneEnterprise(organizations);
if (!organizationId) {
if (enterpriseOrgIds.length === 0) {
return of({
isFreeFamilyPolicyEnabled: false,
belongToOneEnterpriseOrgs,
@@ -145,8 +137,8 @@ export class FreeFamiliesPolicyService {
this.policyService.policiesByType$(PolicyType.FreeFamiliesSponsorshipPolicy, userId),
),
map((policies) => ({
isFreeFamilyPolicyEnabled: policies.some(
(policy) => policy.organizationId === organizationId && policy.enabled,
isFreeFamilyPolicyEnabled: enterpriseOrgIds.every((orgId) =>
policies.some((policy) => policy.organizationId === orgId && policy.enabled),
),
belongToOneEnterpriseOrgs,
belongToMultipleEnterpriseOrgs,
@@ -166,9 +158,4 @@ export class FreeFamiliesPolicyService {
belongToMultipleEnterpriseOrgs: count > 1,
};
}
private getOrganizationIdForOneEnterprise(organizations: any[]): string | null {
const enterpriseOrganizations = organizations.filter((org) => org.canManageSponsorships);
return enterpriseOrganizations.length === 1 ? enterpriseOrganizations[0].id : null;
}
}

View File

@@ -1,10 +1,7 @@
import { Injectable } from "@angular/core";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
import { TaxInformation } from "@bitwarden/common/billing/models/domain/tax-information";
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
@@ -14,17 +11,13 @@ import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.co
providedIn: "root",
})
export class PricingSummaryService {
private estimatedTax: number = 0;
constructor(private taxService: TaxServiceAbstraction) {}
async getPricingSummaryData(
plan: PlanResponse,
sub: OrganizationSubscriptionResponse,
organization: Organization,
selectedInterval: PlanInterval,
taxInformation: TaxInformation,
isSecretsManagerTrial: boolean,
estimatedTax: number,
): Promise<PricingSummaryData> {
// Calculation helpers
const passwordManagerSeatTotal =
@@ -72,14 +65,9 @@ export class PricingSummaryService {
const acceptingSponsorship = false;
const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0;
this.estimatedTax = await this.getEstimatedTax(organization, plan, sub, taxInformation);
const total = organization?.useSecretsManager
? passwordManagerSubtotal +
additionalStorageTotal +
secretsManagerSubtotal +
this.estimatedTax
: passwordManagerSubtotal + additionalStorageTotal + this.estimatedTax;
? passwordManagerSubtotal + additionalStorageTotal + secretsManagerSubtotal + estimatedTax
: passwordManagerSubtotal + additionalStorageTotal + estimatedTax;
return {
selectedPlanInterval: selectedInterval === PlanInterval.Annually ? "year" : "month",
@@ -104,45 +92,10 @@ export class PricingSummaryService {
additionalServiceAccount,
storageGb,
isSecretsManagerTrial,
estimatedTax: this.estimatedTax,
estimatedTax,
};
}
async getEstimatedTax(
organization: Organization,
currentPlan: PlanResponse,
sub: OrganizationSubscriptionResponse,
taxInformation: TaxInformation,
) {
if (!taxInformation || !taxInformation.country || !taxInformation.postalCode) {
return 0;
}
const request: PreviewOrganizationInvoiceRequest = {
organizationId: organization.id,
passwordManager: {
additionalStorage: 0,
plan: currentPlan?.type,
seats: sub.seats,
},
taxInformation: {
postalCode: taxInformation.postalCode,
country: taxInformation.country,
taxId: taxInformation.taxId,
},
};
if (organization.useSecretsManager) {
request.secretsManager = {
seats: sub.smSeats ?? 0,
additionalMachineAccounts:
(sub.smServiceAccounts ?? 0) - (sub.plan.SecretsManager?.baseServiceAccount ?? 0),
};
}
const invoiceResponse = await this.taxService.previewOrganizationInvoice(request);
return invoiceResponse.taxAmount;
}
getAdditionalServiceAccount(plan: PlanResponse, sub: OrganizationSubscriptionResponse): number {
if (!plan || !plan.SecretsManager) {
return 0;

View File

@@ -1,61 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="default" [title]="'addCredit' | i18n">
<ng-container bitDialogContent>
<p bitTypography="body1">{{ "creditDelayed" | i18n }}</p>
<div class="tw-grid tw-grid-cols-2">
<bit-radio-group formControlName="method">
<bit-radio-button id="credit-method-paypal" [value]="paymentMethodType.PayPal">
<bit-label> <i class="bwi bwi-paypal"></i>PayPal</bit-label>
</bit-radio-button>
<bit-radio-button id="credit-method-bitcoin" [value]="paymentMethodType.BitPay">
<bit-label> <i class="bwi bwi-bitcoin"></i>Bitcoin</bit-label>
</bit-radio-button>
</bit-radio-group>
</div>
<div class="tw-grid tw-grid-cols-2">
<bit-form-field>
<bit-label>{{ "amount" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="creditAmount"
(blur)="formatAmount()"
required
/>
<span bitPrefix>$USD</span>
</bit-form-field>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="DialogResult.Cancelled"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>
<form #ppButtonForm action="{{ ppButtonFormAction }}" method="post" target="_top">
<input type="hidden" name="cmd" value="_xclick" />
<input type="hidden" name="business" value="{{ ppButtonBusinessId }}" />
<input type="hidden" name="button_subtype" value="services" />
<input type="hidden" name="no_note" value="1" />
<input type="hidden" name="no_shipping" value="1" />
<input type="hidden" name="rm" value="1" />
<input type="hidden" name="return" value="{{ returnUrl }}" />
<input type="hidden" name="cancel_return" value="{{ returnUrl }}" />
<input type="hidden" name="currency_code" value="USD" />
<input type="hidden" name="image_url" value="https://bitwarden.com/images/paypal-banner.png" />
<input type="hidden" name="bn" value="PP-BuyNowBF:btn_buynow_LG.gif:NonHosted" />
<input type="hidden" name="amount" value="{{ formGroup.get('creditAmount').value }}" />
<input type="hidden" name="custom" value="{{ ppButtonCustomField }}" />
<input type="hidden" name="item_name" value="Bitwarden Account Credit" />
<input type="hidden" name="item_number" value="{{ subject }}" />
</form>

View File

@@ -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<AddCreditDialogData>,
) {
return dialogService.open<AddCreditDialogResult>(AddCreditDialogComponent, config);
}

View File

@@ -1,29 +0,0 @@
<bit-dialog dialogSize="large" [title]="dialogHeader" [loading]="loading">
<ng-container bitDialogContent>
<app-payment
[showAccountCredit]="false"
[showBankAccount]="!!organizationId || !!providerId"
[initialPaymentMethod]="initialPaymentMethod"
></app-payment>
<app-manage-tax-information
*ngIf="taxInformation"
[showTaxIdField]="showTaxIdField"
[startWith]="taxInformation"
(taxInformationChanged)="taxInformationChanged($event)"
/>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary" [bitAction]="submit">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="ResultType.Closed"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -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<AdjustPaymentDialogResultType>,
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<void> => {
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<AdjustPaymentDialogParams>,
) =>
dialogService.open<AdjustPaymentDialogResultType>(AdjustPaymentDialogComponent, dialogConfig);
}

View File

@@ -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,
],

View File

@@ -1,4 +1,2 @@
export * from "./billing-shared.module";
export * from "./payment-method.component";
export * from "./sm-subscribe.component";
export * from "./tax-info.component";

View File

@@ -1,88 +0,0 @@
<app-header *ngIf="organizationId">
<button
type="button"
bitButton
buttonType="secondary"
[bitAction]="load"
class="tw-ml-auto"
*ngIf="firstLoaded"
[disabled]="loading"
>
<i class="bwi bwi-refresh bwi-fw" [ngClass]="{ 'bwi-spin': loading }" aria-hidden="true"></i>
{{ "refresh" | i18n }}
</button>
</app-header>
<bit-container>
<!-- TODO: Organization and individual should use different "page" components -->
<h2 bitTypography="h1" *ngIf="!organizationId">{{ "paymentMethod" | i18n }}</h2>
<ng-container *ngIf="!firstLoaded && loading">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="billing">
<bit-section>
<h2 bitTypography="h2">
{{ (isCreditBalance ? "accountCredit" : "accountBalance") | i18n }}
</h2>
<p class="tw-text-lg tw-font-bold">{{ creditOrBalance | currency: "$" }}</p>
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
<button type="button" bitButton buttonType="secondary" [bitAction]="addCredit">
{{ "addCredit" | i18n }}
</button>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
<p *ngIf="!paymentSource" bitTypography="body1">{{ "noPaymentMethod" | i18n }}</p>
<ng-container *ngIf="paymentSource">
<bit-callout
type="warning"
title="{{ 'verifyBankAccount' | i18n }}"
*ngIf="
forOrganization &&
paymentSource.type === paymentMethodType.BankAccount &&
paymentSource.needsVerification
"
>
<p bitTypography="body1">
{{ "verifyBankAccountDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}
</p>
<form
[formGroup]="verifyBankForm"
[bitSubmit]="verifyBank"
class="tw-flex tw-flex-wrap tw-items-center tw-space-x-2"
>
<bit-form-field class="tw-w-40">
<bit-label>{{ "amountX" | i18n: "1" }}</bit-label>
<input bitInput type="number" step="1" placeholder="xx" formControlName="amount1" />
<span bitPrefix>$0.</span>
</bit-form-field>
<bit-form-field class="tw-w-40">
<bit-label>{{ "amountX" | i18n: "2" }}</bit-label>
<input bitInput type="number" step="1" placeholder="xx" formControlName="amount2" />
<span bitPrefix>$0.</span>
</bit-form-field>
<button bitButton bitFormButton buttonType="primary" type="submit">
{{ "verifyBankAccount" | i18n }}
</button>
</form>
</bit-callout>
<p>
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
{{ paymentSource.description }}
</p>
</ng-container>
<button type="button" bitButton buttonType="secondary" [bitAction]="changePayment">
{{ (paymentSource ? "changePaymentMethod" : "addPaymentMethod") | i18n }}
</button>
<p *ngIf="isUnpaid" bitTypography="body1">
{{ "paymentChargedWithUnpaidSubscription" | i18n }}
</p>
</bit-section>
</ng-container>
</bit-container>

View File

@@ -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<number>(0, [
Validators.required,
Validators.max(99),
Validators.min(0),
]),
amount2: new FormControl<number>(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;
}
}

View File

@@ -1,13 +0,0 @@
<ng-template #defaultContent>
<ng-content></ng-content>
</ng-template>
<div class="tw-relative tw-mt-2">
<bit-label
[attr.for]="for"
class="tw-absolute tw-bg-background tw-px-1 tw-text-sm tw-text-muted -tw-top-2.5 tw-left-3 tw-mb-0 tw-max-w-full tw-pointer-events-auto"
>
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
<span class="tw-text-xs tw-font-normal">({{ "required" | i18n }})</span>
</bit-label>
</div>

View File

@@ -1,149 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<div class="tw-mb-4 tw-text-lg">
<bit-radio-group formControlName="paymentMethod">
<bit-radio-button id="card-payment-method" [value]="PaymentMethodType.Card">
<bit-label>
<i class="bwi bwi-fw bwi-credit-card" aria-hidden="true"></i>
{{ "creditCard" | i18n }}
</bit-label>
</bit-radio-button>
<bit-radio-button
id="bank-payment-method"
[value]="PaymentMethodType.BankAccount"
*ngIf="showBankAccount"
>
<bit-label>
<i class="bwi bwi-fw bwi-billing" aria-hidden="true"></i>
{{ "bankAccount" | i18n }}
</bit-label>
</bit-radio-button>
<bit-radio-button
id="paypal-payment-method"
[value]="PaymentMethodType.PayPal"
*ngIf="showPayPal"
>
<bit-label>
<i class="bwi bwi-fw bwi-paypal" aria-hidden="true"></i>
{{ "payPal" | i18n }}
</bit-label>
</bit-radio-button>
<bit-radio-button
id="credit-payment-method"
[value]="PaymentMethodType.Credit"
*ngIf="showAccountCredit"
>
<bit-label>
<i class="bwi bwi-fw bwi-dollar" aria-hidden="true"></i>
{{ "accountCredit" | i18n }}
</bit-label>
</bit-radio-button>
</bit-radio-group>
</div>
<!-- Card -->
<ng-container *ngIf="usingCard">
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4">
<div class="tw-col-span-1">
<app-payment-label for="stripe-card-number" required>
{{ "number" | i18n }}
</app-payment-label>
<div id="stripe-card-number" class="tw-stripe-form-control"></div>
</div>
<div class="tw-col-span-1 tw-flex tw-items-end">
<img
src="../../../images/cards.png"
alt="Visa, MasterCard, Discover, AmEx, JCB, Diners Club, UnionPay"
class="tw-max-w-full"
/>
</div>
<div class="tw-col-span-1">
<app-payment-label for="stripe-card-expiry" required>
{{ "expiration" | i18n }}
</app-payment-label>
<div id="stripe-card-expiry" class="tw-stripe-form-control"></div>
</div>
<div class="tw-col-span-1">
<app-payment-label for="stripe-card-cvc" required>
{{ "securityCodeSlashCVV" | i18n }}
<a
href="https://www.cvvnumber.com/cvv.html"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
class="hover:tw-no-underline"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</app-payment-label>
<div id="stripe-card-cvc" class="tw-stripe-form-control"></div>
</div>
</div>
</ng-container>
<!-- Bank Account -->
<ng-container *ngIf="showBankAccount && usingBankAccount">
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
{{ "requiredToVerifyBankAccountWithStripe" | i18n }}
</bit-callout>
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4" formGroupName="bankInformation">
<bit-form-field class="tw-col-span-1" disableMargin>
<bit-label>{{ "routingNumber" | i18n }}</bit-label>
<input
bitInput
id="routingNumber"
type="text"
formControlName="routingNumber"
required
appInputVerbatim
/>
</bit-form-field>
<bit-form-field class="tw-col-span-1" disableMargin>
<bit-label>{{ "accountNumber" | i18n }}</bit-label>
<input
bitInput
id="accountNumber"
type="text"
formControlName="accountNumber"
required
appInputVerbatim
/>
</bit-form-field>
<bit-form-field class="tw-col-span-1" disableMargin>
<bit-label>{{ "accountHolderName" | i18n }}</bit-label>
<input
id="accountHolderName"
bitInput
type="text"
formControlName="accountHolderName"
required
appInputVerbatim
/>
</bit-form-field>
<bit-form-field class="tw-col-span-1" disableMargin>
<bit-label>{{ "bankAccountType" | i18n }}</bit-label>
<bit-select id="accountHolderType" formControlName="accountHolderType" required>
<bit-option value="" label="-- {{ 'select' | i18n }} --"></bit-option>
<bit-option value="company" label="{{ 'bankAccountTypeCompany' | i18n }}"></bit-option>
<bit-option
value="individual"
label="{{ 'bankAccountTypeIndividual' | i18n }}"
></bit-option>
</bit-select>
</bit-form-field>
</div>
</ng-container>
<!-- PayPal -->
<ng-container *ngIf="showPayPal && usingPayPal">
<div class="tw-mb-3">
<div id="braintree-container" class="tw-mb-1 tw-content-center"></div>
<small class="tw-text-muted">{{ "paypalClickSubmit" | i18n }}</small>
</div>
</ng-container>
<!-- Account Credit -->
<ng-container *ngIf="showAccountCredit && usingAccountCredit">
<app-callout type="info">
{{ "makeSureEnoughCredit" | i18n }}
</app-callout>
</ng-container>
<button *ngIf="!!onSubmit" bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>
</form>

View File

@@ -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<void>;
@Input() private bankAccountWarningOverride?: string;
@Output() submitted = new EventEmitter<PaymentMethodType>();
private destroy$ = new Subject<void>();
protected formGroup = new FormGroup({
paymentMethod: new FormControl<PaymentMethodType>(null),
bankInformation: new FormGroup({
routingNumber: new FormControl<string>("", [Validators.required]),
accountNumber: new FormControl<string>("", [Validators.required]),
accountHolderName: new FormControl<string>("", [Validators.required]),
accountHolderType: new FormControl<string>("", [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;
}
}

View File

@@ -1,83 +0,0 @@
<form [formGroup]="taxFormGroup">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "country" | i18n }}</bit-label>
<bit-select formControlName="country" autocomplete="country" data-testid="country">
<bit-option
*ngFor="let country of countryList"
[value]="country.value"
[disabled]="country.disabled"
[label]="country.name"
></bit-option>
</bit-select>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="postalCode"
autocomplete="postal-code"
data-testid="postal-code"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="isTaxSupported">
<bit-form-field>
<bit-label>{{ "address1" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="line1"
autocomplete="address-line1"
data-testid="address-line1"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="isTaxSupported">
<bit-form-field>
<bit-label>{{ "address2" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="line2"
autocomplete="address-line2"
data-testid="address-line2"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="isTaxSupported">
<bit-form-field>
<bit-label for="addressCity">{{ "cityTown" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="city"
autocomplete="address-level2"
data-testid="city"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="isTaxSupported">
<bit-form-field>
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="state"
autocomplete="address-level1"
data-testid="state"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="isTaxSupported && showTaxIdField">
<bit-form-field>
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label>
<input bitInput type="text" formControlName="taxId" data-testid="tax-id" />
</bit-form-field>
</div>
</div>
</form>

View File

@@ -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<void>();
@Input() trialFlow = false;
@Output() countryChanged = new EventEmitter();
@Output() taxInformationChanged: EventEmitter<void> = new EventEmitter<void>();
taxFormGroup = new FormGroup({
country: new FormControl<string>(null, [Validators.required]),
postalCode: new FormControl<string>(null, [Validators.required]),
taxId: new FormControl<string>(null),
line1: new FormControl<string>(null),
line2: new FormControl<string>(null),
city: new FormControl<string>(null),
state: new FormControl<string>(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<any> {
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);
}
}

View File

@@ -86,17 +86,13 @@
<ng-container>
<h2 bitTypography="h4">{{ "paymentMethod" | i18n }}</h2>
<ng-container bitDialogContent>
<app-payment
[showAccountCredit]="false"
[showBankAccount]="!!organizationId"
[initialPaymentMethod]="initialPaymentMethod"
></app-payment>
<app-manage-tax-information
*ngIf="taxInformation"
[showTaxIdField]="showTaxIdField"
[startWith]="taxInformation"
(taxInformationChanged)="taxInformationChanged($event)"
/>
<app-enter-payment-method [group]="formGroup.controls.paymentMethod">
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId }"
>
</app-enter-billing-address>
</ng-container>
<!-- Pricing Breakdown -->
<app-pricing-summary

View File

@@ -1,7 +1,17 @@
import { Component, EventEmitter, Inject, OnInit, Output, signal, ViewChild } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import {
Component,
EventEmitter,
Inject,
OnDestroy,
OnInit,
Output,
signal,
ViewChild,
} from "@angular/core";
import { FormGroup } from "@angular/forms";
import { combineLatest, firstValueFrom, map, Subject, takeUntil } from "rxjs";
import { debounceTime, startWith, 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 {
@@ -10,14 +20,9 @@ import {
} 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 { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
import { PaymentMethodType, PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request";
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 { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
@@ -29,9 +34,15 @@ import {
DialogService,
ToastService,
} from "@bitwarden/components";
import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
getBillingAddressFromForm,
} from "@bitwarden/web-vault/app/billing/payment/components";
import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types";
import { PlanCardService } from "../../services/plan-card.service";
import { PaymentComponent } from "../payment/payment.component";
import { PlanCard } from "../plan-card/plan-card.component";
import { PricingSummaryData } from "../pricing-summary/pricing-summary.component";
@@ -60,10 +71,10 @@ interface OnSuccessArgs {
selector: "app-trial-payment-dialog",
templateUrl: "./trial-payment-dialog.component.html",
standalone: false,
providers: [SubscriberBillingClient, TaxClient],
})
export class TrialPaymentDialogComponent implements OnInit {
@ViewChild(PaymentComponent) paymentComponent!: PaymentComponent;
@ViewChild(ManageTaxInformationComponent) taxComponent!: ManageTaxInformationComponent;
export class TrialPaymentDialogComponent implements OnInit, OnDestroy {
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
currentPlan!: PlanResponse;
currentPlanName!: string;
@@ -78,10 +89,16 @@ export class TrialPaymentDialogComponent implements OnInit {
@Output() onSuccess = new EventEmitter<OnSuccessArgs>();
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<void>();
constructor(
@Inject(DIALOG_DATA) private dialogParams: TrialPaymentDialogParams,
private dialogRef: DialogRef<TrialPaymentDialogResultType>,
@@ -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<void> {
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<void> {
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<void> {
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;
}
}

View File

@@ -1,12 +0,0 @@
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
<p>{{ "verifyBankAccountWithStatementDescriptorInstructions" | i18n }}</p>
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-form-field class="tw-mr-2 tw-w-48">
<bit-label>{{ "descriptorCode" | i18n }}</bit-label>
<input bitInput type="text" placeholder="SMAB12" formControlName="descriptorCode" />
</bit-form-field>
<button *ngIf="onSubmit" type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
</form>
</bit-callout>

View File

@@ -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<void>;
@Output() submitted = new EventEmitter();
protected formGroup = this.formBuilder.group({
descriptorCode: new FormControl<string>(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();
};
}

View File

@@ -54,17 +54,7 @@
>
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
name: orgInfoFormGroup.value.name!,
email: orgInfoFormGroup.value.billingEmail!,
type: trialOrganizationType,
}"
[subscriptionProduct]="
product === ProductType.SecretsManager
? SubscriptionProduct.SecretsManager
: SubscriptionProduct.PasswordManager
"
[trialLength]="trialLength"
[trial]="trial"
(steppedBack)="previousStep()"
(organizationCreated)="createdOrganization($event)"
>

View File

@@ -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<void>();
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,
};
}
}

View File

@@ -0,0 +1,87 @@
@if (!(prices$ | async)) {
<ng-container *ngTemplateOutlet="loadingSpinner" />
} @else {
@let prices = prices$ | async;
<form [formGroup]="formGroup" [bitSubmit]="submit">
<div class="tw-container tw-mb-3">
<!-- Cadence -->
<div class="tw-mb-6">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "billingPlanLabel" | i18n }}</h2>
<bit-radio-group [formControl]="formGroup.controls.cadence">
<div class="tw-mb-1 tw-items-center">
<bit-radio-button id="annual-cadence-button" [value]="'annually'">
<bit-label>
{{ "annual" | i18n }} -
{{ prices.annually | currency: "$" }}
/{{ "yr" | i18n }}
</bit-label>
</bit-radio-button>
</div>
@if (prices.monthly) {
<div class="tw-mb-1 tw-items-center">
<bit-radio-button id="monthly-cadence-button" [value]="'monthly'">
<bit-label>
{{ "monthly" | i18n }} -
{{ prices.monthly | currency: "$" }}
/{{ "monthAbbr" | i18n }}
</bit-label>
</bit-radio-button>
</div>
}
</bit-radio-group>
</div>
<!-- Payment -->
<div class="tw-mb-4">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2>
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
></app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: trial().tier !== 'families' }"
></app-enter-billing-address>
@if (trial().length === 0) {
@let label =
trial().product === "passwordManager"
? "passwordManagerPlanPrice"
: "secretsManagerPlanPrice";
<div id="price" class="tw-my-4">
@let selectionTaxAmounts = selectionCosts$ | async;
<div class="tw-text-muted tw-text-base">
{{ label | i18n }}: {{ selectionPrice$ | async | currency: "USD $" }}
<div>
{{ "estimatedTax" | i18n }}:
{{ selectionTaxAmounts.tax | currency: "USD $" }}
</div>
</div>
<hr class="tw-my-1 tw-grid tw-grid-cols-3 tw-ml-0" />
<p class="tw-text-lg">
<strong>{{ "total" | i18n }}: </strong>
@let interval = formGroup.value.cadence === "annually" ? "year" : "month";
{{ selectionTaxAmounts.total | currency: "USD $" }}/{{ interval | i18n }}
</p>
</div>
}
</div>
<!-- Submit -->
<div class="tw-flex tw-space-x-2">
<button bitButton bitFormButton buttonType="primary" type="submit">
{{ (trial().length > 0 ? "startTrial" : "submit") | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" (click)="stepBack()">
{{ "back" | i18n }}
</button>
</div>
</div>
</form>
}
<ng-template #loadingSpinner>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-template>

View File

@@ -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<Trial>();
protected steppedBack = output<void>();
protected organizationCreated = output<OrganizationCreatedEvent>();
private destroy$ = new Subject<void>();
protected prices$!: Observable<Prices>;
protected selectionPrice$!: Observable<number>;
protected selectionCosts$!: Observable<{
tax: number;
total: number;
}>;
protected selectionDescription$!: Observable<string>;
protected formGroup = new FormGroup({
cadence: new FormControl<Cadence>(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<void> => {
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();
}

View File

@@ -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<OrganizationResponse> => {
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);
};
}

View File

@@ -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({

View File

@@ -29,15 +29,32 @@
</div>
</section>
<section *ngIf="state === SetupExtensionState.Success" class="tw-flex tw-flex-col tw-items-center">
<section
*ngIf="state === SetupExtensionState.Success || state === SetupExtensionState.AlreadyInstalled"
class="tw-flex tw-flex-col tw-items-center"
>
<div class="tw-size-[90px]">
<bit-icon [icon]="PartyIcon"></bit-icon>
</div>
<h1 bitTypography="h2" class="tw-mb-6 tw-mt-4">{{ "bitwardenExtensionInstalled" | i18n }}</h1>
<h1 bitTypography="h2" class="tw-mb-6 tw-mt-4 tw-text-center">
{{
(state === SetupExtensionState.Success
? "bitwardenExtensionInstalled"
: "openTheBitwardenExtension"
) | i18n
}}
</h1>
<div
class="tw-flex tw-flex-col tw-rounded-2xl tw-bg-background tw-border tw-border-solid tw-border-secondary-300 tw-p-8"
class="tw-flex tw-flex-col tw-rounded-2xl tw-bg-background tw-border tw-border-solid tw-border-secondary-300 tw-p-8 tw-max-w-md tw-text-center"
>
<p>{{ "openExtensionToAutofill" | i18n }}</p>
<p>
{{
(state === SetupExtensionState.Success
? "openExtensionToAutofill"
: "bitwardenExtensionInstalledOpenExtension"
) | i18n
}}
</p>
<button type="button" bitButton buttonType="primary" class="tw-mb-2" (click)="openExtension()">
{{ "openBitwardenExtension" | i18n }}
</button>

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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<boolean>(
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"]);
};

View File

@@ -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<void> {
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,
);
}

View File

@@ -208,15 +208,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
}
applyOrganizationFilter = async (orgNode: TreeNode<OrganizationFilter>): Promise<void> => {
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",
},

View File

@@ -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;

View File

@@ -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"
>
<bit-no-items [icon]="noItemIcon">
<div slot="title" *ngIf="filter.type === 'archive'">{{ "noItemsInArchive" | i18n }}</div>
<p slot="description" class="tw-text-center tw-max-w-md" *ngIf="filter.type === 'archive'">
{{ "archivedItemsDescription" | i18n }}
<bit-no-items [icon]="(emptyState$ | async)?.icon">
<div slot="title">
{{ (emptyState$ | async)?.title | i18n }}
</div>
<p slot="description" bitTypography="body2" class="tw-max-w-md tw-text-center">
{{ (emptyState$ | async)?.description | i18n }}
</p>
<div slot="title" *ngIf="filter.type !== 'archive'">{{ "noItemsInList" | i18n }}</div>
<button
type="button"
buttonType="primary"
bitButton
(click)="addCipher()"
slot="button"
*ngIf="filter.type !== 'trash' && filter.type !== 'archive'"
*ngIf="showAddCipherBtn"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newItem" | i18n }}

View File

@@ -32,7 +32,14 @@ import {
Unassigned,
} from "@bitwarden/admin-console/common";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { NoResults } from "@bitwarden/assets/svg";
import {
NoResults,
DeactivatedOrg,
EmptyTrash,
FavoritesIcon,
ItemTypes,
Icon,
} from "@bitwarden/assets/svg";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import {
@@ -134,6 +141,16 @@ import { VaultOnboardingComponent } from "./vault-onboarding/vault-onboarding.co
const BroadcasterSubscriptionId = "VaultComponent";
type EmptyStateType = "trash" | "favorites" | "archive";
type EmptyStateItem = {
title: string;
description: string;
icon: Icon;
};
type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
@Component({
selector: "app-vault",
templateUrl: "vault.component.html",
@@ -160,7 +177,11 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
kdfIterations: number;
activeFilter: VaultFilter = new VaultFilter();
protected noItemIcon = NoResults;
protected deactivatedOrgIcon = DeactivatedOrg;
protected emptyTrashIcon = EmptyTrash;
protected favoritesIcon = FavoritesIcon;
protected itemTypesIcon = ItemTypes;
protected noResultsIcon = NoResults;
protected performingInitialLoad = true;
protected refreshing = false;
protected processingEvent = false;
@@ -174,12 +195,16 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
protected isEmpty: boolean;
protected selectedCollection: TreeNode<CollectionView> | undefined;
protected canCreateCollections = false;
protected currentSearchText$: Observable<string>;
protected currentSearchText$: Observable<string> = this.route.queryParams.pipe(
map((queryParams) => queryParams.search),
);
private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>();
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
protected showAddCipherBtn: boolean = false;
organizations$ = this.accountService.activeAccount$
.pipe(map((a) => a?.id))
.pipe(switchMap((id) => this.organizationService.organizations$(id)));
@@ -191,6 +216,64 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}),
);
emptyState$ = combineLatest([
this.currentSearchText$,
this.routedVaultFilterService.filter$,
this.organizations$,
]).pipe(
map(([searchText, filter, organizations]) => {
const selectedOrg = organizations?.find((org) => org.id === filter.organizationId);
const isOrgDisabled = selectedOrg && !selectedOrg.enabled;
if (isOrgDisabled) {
this.showAddCipherBtn = false;
return {
title: "organizationIsSuspended",
description: "organizationIsSuspendedDesc",
icon: this.deactivatedOrgIcon,
};
}
if (searchText) {
return {
title: "noSearchResults",
description: "clearFiltersOrTryAnother",
icon: this.noResultsIcon,
};
}
const emptyStateMap: EmptyStateMap = {
trash: {
title: "noItemsInTrash",
description: "noItemsInTrashDesc",
icon: this.emptyTrashIcon,
},
favorites: {
title: "emptyFavorites",
description: "emptyFavoritesDesc",
icon: this.favoritesIcon,
},
archive: {
title: "noItemsInArchive",
description: "archivedItemsDescription",
icon: this.itemTypesIcon,
},
};
if (filter?.type && filter.type in emptyStateMap) {
this.showAddCipherBtn = false;
return emptyStateMap[filter.type as EmptyStateType];
}
this.showAddCipherBtn = true;
return {
title: "noItemsInVault",
description: "emptyVaultDescription",
icon: this.itemTypesIcon,
};
}),
);
constructor(
private syncService: SyncService,
private route: ActivatedRoute,
@@ -298,8 +381,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}),
);
this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search));
const _ciphers = this.cipherService
.cipherListViews$(activeUserId)
.pipe(filter((c) => c !== null));

View File

@@ -1508,6 +1508,30 @@
"noItemsInList": {
"message": "There are no items to list."
},
"noItemsInTrash": {
"message": "No items in trash"
},
"noItemsInTrashDesc": {
"message": "Items you delete will appear here and be permanently deleted after 30 days"
},
"noItemsInVault": {
"message": "No items in the vault"
},
"emptyVaultDescription": {
"message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here."
},
"emptyFavorites": {
"message": "You haven't favorited any items"
},
"emptyFavoritesDesc": {
"message": "Add frequently used items to favorites for quick access."
},
"noSearchResults": {
"message": "No search results returned"
},
"clearFiltersOrTryAnother": {
"message": "Clear filters or try another search term"
},
"noPermissionToViewAllCollectionItems": {
"message": "You do not have permission to view all items in this collection."
},
@@ -4804,6 +4828,12 @@
"organizationIsDisabled": {
"message": "Organization suspended"
},
"organizationIsSuspended": {
"message": "Organization is suspended"
},
"organizationIsSuspendedDesc": {
"message": "Items in suspended organizations cannot be accessed. Contact your organization owner for assistance."
},
"secretsAccessSuspended": {
"message": "Suspended organizations cannot be accessed. Please contact your organization owner for assistance."
},
@@ -4816,9 +4846,6 @@
"serviceAccountsCannotCreate": {
"message": "Service accounts cannot be created in suspended organizations. Please contact your organization owner for assistance."
},
"disabledOrganizationFilterError": {
"message": "Items in suspended organizations cannot be accessed. Contact your organization owner for assistance."
},
"licenseIsExpired": {
"message": "License is expired."
},
@@ -5192,9 +5219,13 @@
"ssoIdentifier": {
"message": "SSO identifier"
},
"ssoIdentifierHintPartOne": {
"message": "Provide this ID to your members to login with SSO. To bypass this step, set up ",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Provide this ID to your members to login with SSO. To bypass this step, set up Domain verification'"
"ssoIdentifierHint": {
"message": "Provide this ID to your members to login with SSO. Members can skip entering this identifier during SSO if a claimed domain is set up. ",
"description": "This will be used as part of a larger sentence, broken up to include a link. The full sentence will read 'Provide this ID to your members to login with SSO. Members can skip entering this identifier during SSO if a claimed domain is set up. Learn more'"
},
"claimedDomainsLearnMore": {
"message": "Learn more",
"description": "This will be used as part of a larger sentence, broken up to include a link. The full sentence will read 'Provide this ID to your members to login with SSO. Members can skip entering this identifier during SSO if a claimed domain is set up. Learn more'"
},
"unlinkSso": {
"message": "Unlink SSO"
@@ -11047,8 +11078,13 @@
"searchArchive": {
"message": "Search archive"
},
"archive": {
"message": "Archive"
"archiveNoun": {
"message": "Archive",
"description": "Noun"
},
"archiveVerb": {
"message": "Archive",
"description": "Verb"
},
"noItemsInArchive": {
"message": "No items in archive"
@@ -11163,6 +11199,12 @@
"bitwardenExtensionInstalled": {
"message": "Bitwarden extension installed!"
},
"openTheBitwardenExtension": {
"message": "Open the Bitwarden extension"
},
"bitwardenExtensionInstalledOpenExtension": {
"message": "The Bitwarden extension is installed! Open the extension to log in and start autofilling."
},
"openExtensionToAutofill": {
"message": "Open the extension to log in and start autofilling."
},

View File

@@ -39,12 +39,7 @@
*ngIf="canAccessBilling$ | async"
>
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item>
@if (managePaymentDetailsOutsideCheckout$ | async) {
<bit-nav-item
[text]="'paymentDetails' | i18n"
route="billing/payment-details"
></bit-nav-item>
}
<bit-nav-item [text]="'paymentDetails' | i18n" route="billing/payment-details"></bit-nav-item>
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
</bit-nav-group>
<bit-nav-item

View File

@@ -47,7 +47,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
protected canAccessBilling$: Observable<boolean>;
protected clientsTranslationKey$: Observable<string>;
protected managePaymentDetailsOutsideCheckout$: Observable<boolean>;
protected providerPortalTakeover$: Observable<boolean>;
protected subscriber$: Observable<NonIndividualSubscriber>;
@@ -100,10 +99,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
),
);
this.managePaymentDetailsOutsideCheckout$ = this.configService.getFeatureFlag$(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
this.provider$
.pipe(
switchMap((provider) =>

View File

@@ -7,14 +7,18 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CardComponent, ScrollLayoutDirective, SearchModule } from "@bitwarden/components";
import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component";
import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing";
import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component";
import { VerifyBankAccountComponent } from "@bitwarden/web-vault/app/billing/shared/verify-bank-account/verify-bank-account.component";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
} from "@bitwarden/web-vault/app/billing/payment/components";
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
import {
CreateClientDialogComponent,
InvoicesComponent,
ManageClientNameDialogComponent,
ManageClientSubscriptionDialogComponent,
NoInvoicesComponent,
ProviderBillingHistoryComponent,
ProviderSubscriptionComponent,
ProviderSubscriptionStatusComponent,
@@ -52,11 +56,11 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
ProvidersLayoutComponent,
DangerZoneComponent,
ScrollingModule,
VerifyBankAccountComponent,
CardComponent,
ScrollLayoutDirective,
PaymentComponent,
ProviderWarningsModule,
EnterPaymentMethodComponent,
EnterBillingAddressComponent,
],
declarations: [
AcceptProviderComponent,
@@ -72,8 +76,10 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
AddEditMemberDialogComponent,
AddExistingOrganizationDialogComponent,
CreateClientDialogComponent,
InvoicesComponent,
ManageClientNameDialogComponent,
ManageClientSubscriptionDialogComponent,
NoInvoicesComponent,
ProviderBillingHistoryComponent,
ProviderSubscriptionComponent,
ProviderSubscriptionStatusComponent,

View File

@@ -29,13 +29,11 @@
</div>
</div>
<h2 class="tw-mt-5">{{ "paymentMethod" | i18n }}</h2>
<app-payment
[showAccountCredit]="false"
[bankAccountWarningOverride]="
'verifyProviderBankAccountWithStatementDescriptorWarning' | i18n
"
/>
<app-manage-tax-information />
<app-enter-payment-method [group]="formGroup.controls.paymentMethod"></app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: true }"
></app-enter-billing-address>
<button class="tw-mt-8" bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>

View File

@@ -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<void>();
@@ -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);
}
};
}

View File

@@ -30,8 +30,8 @@
<bit-label>{{ "ssoIdentifier" | i18n }}</bit-label>
<input bitInput type="text" formControlName="ssoIdentifier" />
<bit-hint>
{{ "ssoIdentifierHintPartOne" | i18n }}
<a bitLink routerLink="../domain-verification">{{ "claimedDomains" | i18n }}</a>
{{ "ssoIdentifierHint" | i18n }}
<a bitLink routerLink="../domain-verification">{{ "claimedDomainsLearnMore" | i18n }}</a>
</bit-hint>
</bit-form-field>
@@ -209,7 +209,14 @@
<bit-form-field>
<bit-label>{{ "clientSecret" | i18n }}</bit-label>
<input bitInput type="text" formControlName="clientSecret" appInputStripSpaces />
<input bitInput type="password" formControlName="clientSecret" appInputStripSpaces />
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showClientSecret"
></button>
</bit-form-field>
<bit-form-field>
@@ -488,7 +495,6 @@
formControlName="idpSingleSignOnServiceUrl"
appInputStripSpaces
/>
<bit-hint>{{ "idpSingleSignOnServiceUrlRequired" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field>

View File

@@ -121,6 +121,8 @@ export class SsoComponent implements OnInit, OnDestroy {
spMetadataUrl: string;
spAcsUrl: string;
showClientSecret = false;
protected openIdForm = this.formBuilder.group<ControlsOf<SsoConfigView["openId"]>>(
{
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),

Some files were not shown because too many files have changed in this diff Show More