mirror of
https://github.com/bitwarden/browser
synced 2026-02-18 02:19:18 +00:00
Merge branch 'main' of https://github.com/bitwarden/clients into pm-19497-reset-search-x-browser
Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bitwarden/web-vault",
|
||||
"version": "2025.7.0",
|
||||
"version": "2025.7.1",
|
||||
"scripts": {
|
||||
"build:oss": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
|
||||
"build:bit": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-web/webpack.config.js",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CollectionAdminView, CollectionService } from "@bitwarden/admin-console
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
@@ -34,6 +35,7 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
|
||||
stateProvider: StateProvider,
|
||||
collectionService: CollectionService,
|
||||
accountService: AccountService,
|
||||
configService: ConfigService,
|
||||
) {
|
||||
super(
|
||||
organizationService,
|
||||
@@ -44,6 +46,7 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
|
||||
stateProvider,
|
||||
collectionService,
|
||||
accountService,
|
||||
configService,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,62 +25,67 @@
|
||||
</bit-breadcrumbs>
|
||||
|
||||
<ng-container slot="title-suffix">
|
||||
<ng-container
|
||||
*ngIf="
|
||||
collection != null && (canEditCollection || canDeleteCollection || canViewCollectionInfo)
|
||||
"
|
||||
>
|
||||
<button
|
||||
bitIconButton="bwi-angle-down"
|
||||
[bitMenuTriggerFor]="editCollectionMenu"
|
||||
size="small"
|
||||
type="button"
|
||||
></button>
|
||||
<bit-menu #editCollectionMenu>
|
||||
<ng-container *ngIf="canEditCollection">
|
||||
@if (
|
||||
collection != null && (canEditCollection || canDeleteCollection || canViewCollectionInfo)
|
||||
) {
|
||||
<ng-container>
|
||||
<button
|
||||
bitIconButton="bwi-angle-down"
|
||||
[bitMenuTriggerFor]="editCollectionMenu"
|
||||
size="small"
|
||||
type="button"
|
||||
></button>
|
||||
<bit-menu #editCollectionMenu>
|
||||
<ng-container *ngIf="canEditCollection">
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="editCollection(CollectionDialogTabType.Info, false)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
|
||||
{{ "editInfo" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="editCollection(CollectionDialogTabType.Access, false)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
|
||||
{{ "access" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!canEditCollection && canViewCollectionInfo">
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="editCollection(CollectionDialogTabType.Info, true)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
|
||||
{{ "viewInfo" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="editCollection(CollectionDialogTabType.Access, true)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
|
||||
{{ "viewAccess" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
<button
|
||||
type="button"
|
||||
*ngIf="canDeleteCollection"
|
||||
bitMenuItem
|
||||
(click)="editCollection(CollectionDialogTabType.Info, false)"
|
||||
(click)="deleteCollection()"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
|
||||
{{ "editInfo" | i18n }}
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
|
||||
{{ "delete" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="editCollection(CollectionDialogTabType.Access, false)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
|
||||
{{ "access" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!canEditCollection && canViewCollectionInfo">
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="editCollection(CollectionDialogTabType.Info, true)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
|
||||
{{ "viewInfo" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="editCollection(CollectionDialogTabType.Access, true)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
|
||||
{{ "viewAccess" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
<button type="button" *ngIf="canDeleteCollection" bitMenuItem (click)="deleteCollection()">
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
|
||||
{{ "delete" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
</ng-container>
|
||||
</bit-menu>
|
||||
</ng-container>
|
||||
}
|
||||
<small *ngIf="loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
@@ -103,48 +108,11 @@
|
||||
*ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned && organization"
|
||||
class="tw-shrink-0"
|
||||
>
|
||||
<!-- "New" menu is always shown unless the user cannot create a cipher and cannot create a collection-->
|
||||
<ng-container *ngIf="canCreateCipher || canCreateCollection">
|
||||
<div appListDropdown>
|
||||
<button
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
type="button"
|
||||
[bitMenuTriggerFor]="addOptions"
|
||||
id="newItemDropdown"
|
||||
appA11yTitle="{{ 'new' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-plus" aria-hidden="true"></i>
|
||||
{{ "new" | i18n }}<i class="bwi tw-ml-2" aria-hidden="true"></i>
|
||||
</button>
|
||||
<bit-menu #addOptions aria-labelledby="newItemDropdown">
|
||||
<ng-container *ngIf="canCreateCipher">
|
||||
<button type="button" bitMenuItem (click)="addCipher(CipherType.Login)">
|
||||
<i class="bwi bwi-globe" slot="start" aria-hidden="true"></i>
|
||||
{{ "typeLogin" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="addCipher(CipherType.Card)">
|
||||
<i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i>
|
||||
{{ "typeCard" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="addCipher(CipherType.Identity)">
|
||||
<i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i>
|
||||
{{ "typeIdentity" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="addCipher(CipherType.SecureNote)">
|
||||
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
|
||||
{{ "note" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="canCreateCollection">
|
||||
<bit-menu-divider *ngIf="canCreateCipher"></bit-menu-divider>
|
||||
<button type="button" bitMenuItem (click)="addCollection()">
|
||||
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
|
||||
{{ "collection" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-menu>
|
||||
</div>
|
||||
</ng-container>
|
||||
<vault-new-cipher-menu
|
||||
[canCreateCipher]="canCreateCipher"
|
||||
[canCreateCollection]="canCreateCollection"
|
||||
(cipherAdded)="addCipher($event)"
|
||||
(collectionAdded)="addCollection()"
|
||||
/>
|
||||
</div>
|
||||
</app-header>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
SearchModule,
|
||||
SimpleDialogOptions,
|
||||
} from "@bitwarden/components";
|
||||
import { NewCipherMenuComponent } from "@bitwarden/vault";
|
||||
|
||||
import { HeaderModule } from "../../../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../../../shared";
|
||||
@@ -45,6 +46,7 @@ import { CollectionDialogTabType } from "../../shared/components/collection-dial
|
||||
HeaderModule,
|
||||
SearchModule,
|
||||
JslibModule,
|
||||
NewCipherMenuComponent,
|
||||
],
|
||||
})
|
||||
export class VaultHeaderComponent {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<app-free-trial-warning
|
||||
<app-organization-free-trial-warning
|
||||
*ngIf="useOrganizationWarningsService$ | async"
|
||||
[organization]="organization"
|
||||
(clicked)="navigateToPaymentMethod()"
|
||||
>
|
||||
</app-free-trial-warning>
|
||||
<app-reseller-renewal-warning
|
||||
</app-organization-free-trial-warning>
|
||||
<app-organization-reseller-renewal-warning
|
||||
*ngIf="useOrganizationWarningsService$ | async"
|
||||
[organization]="organization"
|
||||
>
|
||||
</app-reseller-renewal-warning>
|
||||
</app-organization-reseller-renewal-warning>
|
||||
<ng-container *ngIf="freeTrialWhenWarningsServiceDisabled$ | async as freeTrial">
|
||||
<bit-banner
|
||||
id="free-trial-banner"
|
||||
|
||||
@@ -78,8 +78,8 @@ import {
|
||||
DecryptionFailureDialogComponent,
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/services/organization-warnings.service";
|
||||
import { ResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/reseller-renewal-warning.component";
|
||||
import { OrganizationResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components/organization-reseller-renewal-warning.component";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/warnings/services/organization-warnings.service";
|
||||
|
||||
import { BillingNotificationService } from "../../../billing/services/billing-notification.service";
|
||||
import {
|
||||
@@ -88,7 +88,7 @@ import {
|
||||
} from "../../../billing/services/reseller-warning.service";
|
||||
import { TrialFlowService } from "../../../billing/services/trial-flow.service";
|
||||
import { FreeTrial } from "../../../billing/types/free-trial";
|
||||
import { FreeTrialWarningComponent } from "../../../billing/warnings/free-trial-warning.component";
|
||||
import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components/organization-free-trial-warning.component";
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections";
|
||||
import {
|
||||
@@ -125,7 +125,7 @@ import {
|
||||
BulkCollectionsDialogResult,
|
||||
} from "./bulk-collections-dialog";
|
||||
import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component";
|
||||
import { getNestedCollectionTree, getFlatCollectionTree } from "./utils";
|
||||
import { getFlatCollectionTree, getNestedCollectionTree } from "./utils";
|
||||
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
|
||||
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
|
||||
|
||||
@@ -150,8 +150,8 @@ enum AddAccessStatusType {
|
||||
SharedModule,
|
||||
BannerModule,
|
||||
NoItemsModule,
|
||||
FreeTrialWarningComponent,
|
||||
ResellerRenewalWarningComponent,
|
||||
OrganizationFreeTrialWarningComponent,
|
||||
OrganizationResellerRenewalWarningComponent,
|
||||
],
|
||||
providers: [
|
||||
RoutedVaultFilterService,
|
||||
@@ -536,7 +536,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
const filterFunction = createFilterFunction(filter);
|
||||
|
||||
if (await this.searchService.isSearchable(this.userId, searchText)) {
|
||||
return await this.searchService.searchCiphers(
|
||||
return await this.searchService.searchCiphers<CipherView>(
|
||||
this.userId,
|
||||
searchText,
|
||||
[filterFunction],
|
||||
@@ -749,10 +749,13 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async navigateToPaymentMethod() {
|
||||
await this.router.navigate(
|
||||
["organizations", `${this.organization?.id}`, "billing", "payment-method"],
|
||||
{ state: { launchPaymentModalAutomatically: true } },
|
||||
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
|
||||
);
|
||||
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
|
||||
await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], {
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
});
|
||||
}
|
||||
|
||||
addAccessToggle(e: AddAccessStatusType) {
|
||||
@@ -769,7 +772,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async onVaultItemsEvent(event: VaultItemEvent) {
|
||||
async onVaultItemsEvent(event: VaultItemEvent<CipherView>) {
|
||||
this.processingEvent = true;
|
||||
|
||||
try {
|
||||
@@ -1065,6 +1068,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
if (unassignedCiphers.length > 0 || editAccessCiphers.length > 0) {
|
||||
await this.cipherService.restoreManyWithServer(
|
||||
[...unassignedCiphers, ...editAccessCiphers],
|
||||
this.userId,
|
||||
this.organization.id,
|
||||
);
|
||||
}
|
||||
@@ -1135,10 +1139,11 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
message: this.i18nService.t("deletedCollectionId", collection.name),
|
||||
});
|
||||
|
||||
// Clear the cipher cache to clear the deleted collection from the cipher state
|
||||
await this.cipherService.clear();
|
||||
|
||||
// Navigate away if we deleted the collection we were viewing
|
||||
if (this.selectedCollection?.node.id === collection.id) {
|
||||
// Clear the cipher cache to clear the deleted collection from the cipher state
|
||||
await this.cipherService.clear();
|
||||
void this.router.navigate([], {
|
||||
queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null },
|
||||
queryParamsHandling: "merge",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
|
||||
<bit-nav-group
|
||||
icon="bwi-filter"
|
||||
*ngIf="organization.useRiskInsights"
|
||||
*ngIf="organization.useRiskInsights && organization.canAccessReports"
|
||||
[text]="'accessIntelligence' | i18n"
|
||||
route="access-intelligence"
|
||||
>
|
||||
@@ -74,7 +74,11 @@
|
||||
>
|
||||
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item>
|
||||
<ng-container *ngIf="(showPaymentAndHistory$ | async) && (organizationIsUnmanaged$ | async)">
|
||||
<bit-nav-item [text]="'paymentMethod' | i18n" route="billing/payment-method"></bit-nav-item>
|
||||
@let paymentDetailsPageData = paymentDetailsPageData$ | async;
|
||||
<bit-nav-item
|
||||
[text]="paymentDetailsPageData.textKey | i18n"
|
||||
[route]="paymentDetailsPageData.route"
|
||||
></bit-nav-item>
|
||||
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
|
||||
</ng-container>
|
||||
</bit-nav-group>
|
||||
|
||||
@@ -27,12 +27,11 @@ 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";
|
||||
import { BannerModule, IconModule, AdminConsoleLogo } from "@bitwarden/components";
|
||||
|
||||
import { FreeFamiliesPolicyService } from "../../../billing/services/free-families-policy.service";
|
||||
import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component";
|
||||
import { WebLayoutModule } from "../../../layouts/web-layout.module";
|
||||
import { AdminConsoleLogo } from "../../icons/admin-console-logo";
|
||||
|
||||
@Component({
|
||||
selector: "app-organization-layout",
|
||||
@@ -65,6 +64,11 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
protected showSponsoredFamiliesDropdown$: Observable<boolean>;
|
||||
protected canShowPoliciesTab$: Observable<boolean>;
|
||||
|
||||
protected paymentDetailsPageData$: Observable<{
|
||||
route: string;
|
||||
textKey: string;
|
||||
}>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private organizationService: OrganizationService,
|
||||
@@ -136,6 +140,16 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.paymentDetailsPageData$ = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
|
||||
.pipe(
|
||||
map((managePaymentDetailsOutsideCheckout) =>
|
||||
managePaymentDetailsOutsideCheckout
|
||||
? { route: "billing/payment-details", textKey: "paymentDetails" }
|
||||
: { route: "billing/payment-method", textKey: "paymentMethod" },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
canShowVaultTab(organization: Organization): boolean {
|
||||
|
||||
@@ -134,17 +134,11 @@
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
formControlName="manageUsers"
|
||||
(change)="handleDependentPermissions()"
|
||||
/>
|
||||
<bit-label>{{ "manageUsers" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
<bit-form-control>
|
||||
<input
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
formControlName="manageResetPassword"
|
||||
(change)="handleDependentPermissions()"
|
||||
/>
|
||||
<input type="checkbox" bitCheckbox formControlName="manageResetPassword" />
|
||||
<bit-label>{{ "manageAccountRecovery" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,6 @@ import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permi
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
@@ -453,28 +452,6 @@ export class MemberDialogComponent implements OnDestroy {
|
||||
return Object.assign(p, partialPermissions);
|
||||
}
|
||||
|
||||
async handleDependentPermissions() {
|
||||
const separateCustomRolePermissions = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.SeparateCustomRolePermissions,
|
||||
);
|
||||
if (separateCustomRolePermissions) {
|
||||
return;
|
||||
}
|
||||
// Manage Password Reset (Account Recovery) must have Manage Users enabled
|
||||
if (
|
||||
this.permissionsGroup.value.manageResetPassword &&
|
||||
!this.permissionsGroup.value.manageUsers
|
||||
) {
|
||||
this.permissionsGroup.value.manageUsers = true;
|
||||
(document.getElementById("manageUsers") as HTMLInputElement).checked = true;
|
||||
this.toastService.showToast({
|
||||
variant: "info",
|
||||
title: null,
|
||||
message: this.i18nService.t("accountRecoveryManageUsers"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
|
||||
@@ -244,18 +244,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// Setup feature flag-dependent observables
|
||||
const separateCustomRolePermissionsEnabled$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.SeparateCustomRolePermissions,
|
||||
);
|
||||
this.showUserManagementControls$ = combineLatest([
|
||||
separateCustomRolePermissionsEnabled$,
|
||||
organization$,
|
||||
]).pipe(
|
||||
map(
|
||||
([separateCustomRolePermissionsEnabled, organization]) =>
|
||||
!separateCustomRolePermissionsEnabled || organization.canManageUsers,
|
||||
),
|
||||
this.showUserManagementControls$ = organization$.pipe(
|
||||
map((organization) => organization.canManageUsers),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
|
||||
import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import {
|
||||
EncryptedString,
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { ChangePasswordService, DefaultChangePasswordService } from "@bitwarden/auth/angular";
|
||||
import {
|
||||
ChangePasswordService,
|
||||
DefaultChangePasswordService,
|
||||
} from "@bitwarden/angular/auth/password-management/change-password";
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { RouterService } from "@bitwarden/web-vault/app/core";
|
||||
import { UserKeyRotationService } from "@bitwarden/web-vault/app/key-management/key-rotation/user-key-rotation.service";
|
||||
|
||||
export class WebChangePasswordService
|
||||
@@ -14,6 +18,7 @@ export class WebChangePasswordService
|
||||
protected masterPasswordApiService: MasterPasswordApiService,
|
||||
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private userKeyRotationService: UserKeyRotationService,
|
||||
private routerService: RouterService,
|
||||
) {
|
||||
super(keyService, masterPasswordApiService, masterPasswordService);
|
||||
}
|
||||
@@ -31,4 +36,8 @@ export class WebChangePasswordService
|
||||
newPasswordHint,
|
||||
);
|
||||
}
|
||||
|
||||
async clearDeeplinkState() {
|
||||
await this.routerService.getAndClearLoginRedirectUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
LoginDecryptionOptionsService,
|
||||
DefaultLoginDecryptionOptionsService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
|
||||
import { RouterService } from "../../../../core/router.service";
|
||||
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
|
||||
|
||||
export class WebLoginDecryptionOptionsService
|
||||
extends DefaultLoginDecryptionOptionsService
|
||||
@@ -16,7 +16,7 @@ export class WebLoginDecryptionOptionsService
|
||||
constructor(
|
||||
protected messagingService: MessagingService,
|
||||
private routerService: RouterService,
|
||||
private acceptOrganizationInviteService: AcceptOrganizationInviteService,
|
||||
private organizationInviteService: OrganizationInviteService,
|
||||
) {
|
||||
super(messagingService);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export class WebLoginDecryptionOptionsService
|
||||
// accepted while being enrolled in admin recovery. So we need to clear
|
||||
// the redirect and stored org invite.
|
||||
await this.routerService.getAndClearLoginRedirectUrl();
|
||||
await this.acceptOrganizationInviteService.clearOrganizationInvitation();
|
||||
await this.organizationInviteService.clearOrganizationInvitation();
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -22,7 +24,6 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { RouterService } from "../../../../../../../../apps/web/src/app/core";
|
||||
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
|
||||
|
||||
import { WebLoginComponentService } from "./web-login-component.service";
|
||||
|
||||
@@ -32,7 +33,7 @@ jest.mock("../../../../../utils/flags", () => ({
|
||||
|
||||
describe("WebLoginComponentService", () => {
|
||||
let service: WebLoginComponentService;
|
||||
let acceptOrganizationInviteService: MockProxy<AcceptOrganizationInviteService>;
|
||||
let organizationInviteService: MockProxy<OrganizationInviteService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
|
||||
let internalPolicyService: MockProxy<InternalPolicyService>;
|
||||
@@ -44,9 +45,10 @@ describe("WebLoginComponentService", () => {
|
||||
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
|
||||
beforeEach(() => {
|
||||
acceptOrganizationInviteService = mock<AcceptOrganizationInviteService>();
|
||||
organizationInviteService = mock<OrganizationInviteService>();
|
||||
logService = mock<LogService>();
|
||||
policyApiService = mock<PolicyApiServiceAbstraction>();
|
||||
internalPolicyService = mock<InternalPolicyService>();
|
||||
@@ -57,12 +59,13 @@ describe("WebLoginComponentService", () => {
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
ssoLoginService = mock<SsoLoginServiceAbstraction>();
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WebLoginComponentService,
|
||||
{ provide: DefaultLoginComponentService, useClass: WebLoginComponentService },
|
||||
{ provide: AcceptOrganizationInviteService, useValue: acceptOrganizationInviteService },
|
||||
{ provide: OrganizationInviteService, useValue: organizationInviteService },
|
||||
{ provide: LogService, useValue: logService },
|
||||
{ provide: PolicyApiServiceAbstraction, useValue: policyApiService },
|
||||
{ provide: InternalPolicyService, useValue: internalPolicyService },
|
||||
@@ -73,6 +76,7 @@ describe("WebLoginComponentService", () => {
|
||||
{ provide: PlatformUtilsService, useValue: platformUtilsService },
|
||||
{ provide: SsoLoginServiceAbstraction, useValue: ssoLoginService },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(WebLoginComponentService);
|
||||
@@ -84,14 +88,14 @@ describe("WebLoginComponentService", () => {
|
||||
|
||||
describe("getOrgPoliciesFromOrgInvite", () => {
|
||||
it("returns undefined if organization invite is null", async () => {
|
||||
acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
const result = await service.getOrgPoliciesFromOrgInvite();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("logs an error if getPoliciesByToken throws an error", async () => {
|
||||
const error = new Error("Test error");
|
||||
acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue({
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue({
|
||||
organizationId: "org-id",
|
||||
token: "token",
|
||||
email: "email",
|
||||
@@ -117,7 +121,7 @@ describe("WebLoginComponentService", () => {
|
||||
const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions();
|
||||
resetPasswordPolicyOptions.autoEnrollEnabled = autoEnrollEnabled;
|
||||
|
||||
acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue({
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue({
|
||||
organizationId: "org-id",
|
||||
token: "token",
|
||||
email: "email",
|
||||
|
||||
@@ -11,18 +11,21 @@ import {
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
|
||||
import { RouterService } from "../../../../core/router.service";
|
||||
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
|
||||
|
||||
@Injectable()
|
||||
export class WebLoginComponentService
|
||||
@@ -30,7 +33,7 @@ export class WebLoginComponentService
|
||||
implements LoginComponentService
|
||||
{
|
||||
constructor(
|
||||
protected acceptOrganizationInviteService: AcceptOrganizationInviteService,
|
||||
protected organizationInviteService: OrganizationInviteService,
|
||||
protected logService: LogService,
|
||||
protected policyApiService: PolicyApiServiceAbstraction,
|
||||
protected policyService: InternalPolicyService,
|
||||
@@ -42,6 +45,7 @@ export class WebLoginComponentService
|
||||
ssoLoginService: SsoLoginServiceAbstraction,
|
||||
private router: Router,
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super(
|
||||
cryptoFunctionService,
|
||||
@@ -66,8 +70,8 @@ export class WebLoginComponentService
|
||||
return;
|
||||
}
|
||||
|
||||
async getOrgPoliciesFromOrgInvite(): Promise<PasswordPolicies | null> {
|
||||
const orgInvite = await this.acceptOrganizationInviteService.getOrganizationInvite();
|
||||
async getOrgPoliciesFromOrgInvite(): Promise<PasswordPolicies | undefined> {
|
||||
const orgInvite = await this.organizationInviteService.getOrganizationInvite();
|
||||
|
||||
if (orgInvite != null) {
|
||||
let policies: Policy[];
|
||||
@@ -84,7 +88,7 @@ export class WebLoginComponentService
|
||||
}
|
||||
|
||||
if (policies == null) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resetPasswordPolicy = this.policyService.getResetPasswordPolicyOptions(
|
||||
@@ -95,12 +99,23 @@ export class WebLoginComponentService
|
||||
const isPolicyAndAutoEnrollEnabled =
|
||||
resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled;
|
||||
|
||||
const enforcedPasswordPolicyOptions = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId, policies)),
|
||||
),
|
||||
);
|
||||
let enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions;
|
||||
|
||||
if (
|
||||
await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor)
|
||||
) {
|
||||
enforcedPasswordPolicyOptions =
|
||||
this.policyService.combinePoliciesIntoMasterPasswordPolicyOptions(policies);
|
||||
} else {
|
||||
enforcedPasswordPolicyOptions = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.masterPasswordPolicyOptions$(userId, policies),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
policies,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
|
||||
import { ORGANIZATION_INVITE } from "@bitwarden/common/auth/services/organization-invite/organization-invite-state";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { GlobalState, GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
export class WebOrganizationInviteService implements OrganizationInviteService {
|
||||
private organizationInvitationState: GlobalState<OrganizationInvite | null>;
|
||||
|
||||
constructor(private readonly globalStateProvider: GlobalStateProvider) {
|
||||
this.organizationInvitationState = this.globalStateProvider.get(ORGANIZATION_INVITE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently stored organization invite
|
||||
*/
|
||||
async getOrganizationInvite(): Promise<OrganizationInvite | null> {
|
||||
return await firstValueFrom(this.organizationInvitationState.state$);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a new organization invite
|
||||
* @param invite an organization invite
|
||||
* @throws if the invite is nullish
|
||||
*/
|
||||
async setOrganizationInvitation(invite: OrganizationInvite): Promise<void> {
|
||||
if (invite == null) {
|
||||
throw new Error("Invite cannot be null. Use clearOrganizationInvitation instead.");
|
||||
}
|
||||
await this.organizationInvitationState.update(() => invite);
|
||||
}
|
||||
|
||||
/** Clears the currently stored organization invite */
|
||||
async clearOrganizationInvitation(): Promise<void> {
|
||||
await this.organizationInvitationState.update(() => null);
|
||||
}
|
||||
}
|
||||
@@ -15,17 +15,17 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import { AcceptOrganizationInviteService } from "@bitwarden/web-vault/app/auth/organization-invite/accept-organization.service";
|
||||
import { RouterService } from "@bitwarden/web-vault/app/core";
|
||||
|
||||
import { WebSetInitialPasswordService } from "./web-set-initial-password.service";
|
||||
@@ -43,7 +43,7 @@ describe("WebSetInitialPasswordService", () => {
|
||||
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
|
||||
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
|
||||
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
||||
let acceptOrganizationInviteService: MockProxy<AcceptOrganizationInviteService>;
|
||||
let organizationInviteService: MockProxy<OrganizationInviteService>;
|
||||
let routerService: MockProxy<RouterService>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -57,7 +57,7 @@ describe("WebSetInitialPasswordService", () => {
|
||||
organizationApiService = mock<OrganizationApiServiceAbstraction>();
|
||||
organizationUserApiService = mock<OrganizationUserApiService>();
|
||||
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
|
||||
acceptOrganizationInviteService = mock<AcceptOrganizationInviteService>();
|
||||
organizationInviteService = mock<OrganizationInviteService>();
|
||||
routerService = mock<RouterService>();
|
||||
|
||||
sut = new WebSetInitialPasswordService(
|
||||
@@ -71,7 +71,7 @@ describe("WebSetInitialPasswordService", () => {
|
||||
organizationApiService,
|
||||
organizationUserApiService,
|
||||
userDecryptionOptionsService,
|
||||
acceptOrganizationInviteService,
|
||||
organizationInviteService,
|
||||
routerService,
|
||||
);
|
||||
});
|
||||
@@ -169,9 +169,7 @@ describe("WebSetInitialPasswordService", () => {
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(acceptOrganizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,7 +199,7 @@ describe("WebSetInitialPasswordService", () => {
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow();
|
||||
expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled();
|
||||
expect(acceptOrganizationInviteService.clearOrganizationInvitation).not.toHaveBeenCalled();
|
||||
expect(organizationInviteService.clearOrganizationInvitation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,12 +9,12 @@ import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import { AcceptOrganizationInviteService } from "@bitwarden/web-vault/app/auth/organization-invite/accept-organization.service";
|
||||
import { RouterService } from "@bitwarden/web-vault/app/core";
|
||||
|
||||
export class WebSetInitialPasswordService
|
||||
@@ -32,7 +32,7 @@ export class WebSetInitialPasswordService
|
||||
protected organizationApiService: OrganizationApiServiceAbstraction,
|
||||
protected organizationUserApiService: OrganizationUserApiService,
|
||||
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
private acceptOrganizationInviteService: AcceptOrganizationInviteService,
|
||||
private organizationInviteService: OrganizationInviteService,
|
||||
private routerService: RouterService,
|
||||
) {
|
||||
super(
|
||||
@@ -78,6 +78,6 @@ export class WebSetInitialPasswordService
|
||||
* as clear the org invite itself that was originally set in state by the AcceptOrganizationComponent.
|
||||
*/
|
||||
await this.routerService.getAndClearLoginRedirectUrl();
|
||||
await this.acceptOrganizationInviteService.clearOrganizationInvitation();
|
||||
await this.organizationInviteService.clearOrganizationInvitation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,16 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||
import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
|
||||
import { OrganizationInvite } from "../../../organization-invite/organization-invite";
|
||||
|
||||
import { WebRegistrationFinishService } from "./web-registration-finish.service";
|
||||
|
||||
describe("WebRegistrationFinishService", () => {
|
||||
@@ -29,30 +26,29 @@ describe("WebRegistrationFinishService", () => {
|
||||
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let accountApiService: MockProxy<AccountApiService>;
|
||||
let acceptOrgInviteService: MockProxy<AcceptOrganizationInviteService>;
|
||||
let organizationInviteService: MockProxy<OrganizationInviteService>;
|
||||
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
|
||||
beforeEach(() => {
|
||||
keyService = mock<KeyService>();
|
||||
accountApiService = mock<AccountApiService>();
|
||||
acceptOrgInviteService = mock<AcceptOrganizationInviteService>();
|
||||
organizationInviteService = mock<OrganizationInviteService>();
|
||||
policyApiService = mock<PolicyApiServiceAbstraction>();
|
||||
logService = mock<LogService>();
|
||||
policyService = mock<PolicyService>();
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
service = new WebRegistrationFinishService(
|
||||
keyService,
|
||||
accountApiService,
|
||||
acceptOrgInviteService,
|
||||
organizationInviteService,
|
||||
policyApiService,
|
||||
logService,
|
||||
policyService,
|
||||
accountService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,21 +68,21 @@ describe("WebRegistrationFinishService", () => {
|
||||
});
|
||||
|
||||
it("returns null when the org invite is null", async () => {
|
||||
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getOrgNameFromOrgInvite();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled();
|
||||
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the organization name from the organization invite when it exists", async () => {
|
||||
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
|
||||
|
||||
const result = await service.getOrgNameFromOrgInvite();
|
||||
|
||||
expect(result).toEqual(orgInvite.organizationName);
|
||||
expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled();
|
||||
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,22 +98,22 @@ describe("WebRegistrationFinishService", () => {
|
||||
});
|
||||
|
||||
it("returns null when the org invite is null", async () => {
|
||||
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getMasterPasswordPolicyOptsFromOrgInvite();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled();
|
||||
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when the policies are null", async () => {
|
||||
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
|
||||
policyApiService.getPoliciesByToken.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getMasterPasswordPolicyOptsFromOrgInvite();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled();
|
||||
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled();
|
||||
expect(policyApiService.getPoliciesByToken).toHaveBeenCalledWith(
|
||||
orgInvite.organizationId,
|
||||
orgInvite.token,
|
||||
@@ -127,13 +123,13 @@ describe("WebRegistrationFinishService", () => {
|
||||
});
|
||||
|
||||
it("logs an error and returns null when policies cannot be fetched", async () => {
|
||||
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
|
||||
policyApiService.getPoliciesByToken.mockRejectedValue(new Error("error"));
|
||||
|
||||
const result = await service.getMasterPasswordPolicyOptsFromOrgInvite();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled();
|
||||
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled();
|
||||
expect(policyApiService.getPoliciesByToken).toHaveBeenCalledWith(
|
||||
orgInvite.organizationId,
|
||||
orgInvite.token,
|
||||
@@ -147,14 +143,14 @@ describe("WebRegistrationFinishService", () => {
|
||||
const masterPasswordPolicies = [new Policy()];
|
||||
const masterPasswordPolicyOptions = new MasterPasswordPolicyOptions();
|
||||
|
||||
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
|
||||
policyApiService.getPoliciesByToken.mockResolvedValue(masterPasswordPolicies);
|
||||
policyService.masterPasswordPolicyOptions$.mockReturnValue(of(masterPasswordPolicyOptions));
|
||||
|
||||
const result = await service.getMasterPasswordPolicyOptsFromOrgInvite();
|
||||
|
||||
expect(result).toEqual(masterPasswordPolicyOptions);
|
||||
expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled();
|
||||
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled();
|
||||
expect(policyApiService.getPoliciesByToken).toHaveBeenCalledWith(
|
||||
orgInvite.organizationId,
|
||||
orgInvite.token,
|
||||
@@ -221,7 +217,7 @@ describe("WebRegistrationFinishService", () => {
|
||||
keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]);
|
||||
keyService.makeKeyPair.mockResolvedValue(userKeyPair);
|
||||
accountApiService.registerFinish.mockResolvedValue();
|
||||
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
|
||||
await service.finishRegistration(email, passwordInputResult, emailVerificationToken);
|
||||
|
||||
@@ -257,7 +253,7 @@ describe("WebRegistrationFinishService", () => {
|
||||
keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]);
|
||||
keyService.makeKeyPair.mockResolvedValue(userKeyPair);
|
||||
accountApiService.registerFinish.mockResolvedValue();
|
||||
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
|
||||
|
||||
await service.finishRegistration(email, passwordInputResult);
|
||||
|
||||
@@ -293,7 +289,7 @@ describe("WebRegistrationFinishService", () => {
|
||||
keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]);
|
||||
keyService.makeKeyPair.mockResolvedValue(userKeyPair);
|
||||
accountApiService.registerFinish.mockResolvedValue();
|
||||
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
|
||||
await service.finishRegistration(
|
||||
email,
|
||||
@@ -334,7 +330,7 @@ describe("WebRegistrationFinishService", () => {
|
||||
keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]);
|
||||
keyService.makeKeyPair.mockResolvedValue(userKeyPair);
|
||||
accountApiService.registerFinish.mockResolvedValue();
|
||||
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
|
||||
await service.finishRegistration(
|
||||
email,
|
||||
@@ -377,7 +373,7 @@ describe("WebRegistrationFinishService", () => {
|
||||
keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]);
|
||||
keyService.makeKeyPair.mockResolvedValue(userKeyPair);
|
||||
accountApiService.registerFinish.mockResolvedValue();
|
||||
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
|
||||
await service.finishRegistration(
|
||||
email,
|
||||
@@ -418,4 +414,22 @@ describe("WebRegistrationFinishService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("determineLoginSuccessRoute", () => {
|
||||
it("returns /setup-extension when the end user activation feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const result = await service.determineLoginSuccessRoute();
|
||||
|
||||
expect(result).toBe("/setup-extension");
|
||||
});
|
||||
|
||||
it("returns /vault when the end user activation feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const result = await service.determineLoginSuccessRoute();
|
||||
|
||||
expect(result).toBe("/vault");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,14 +12,17 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import {
|
||||
EncryptedString,
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
|
||||
|
||||
export class WebRegistrationFinishService
|
||||
extends DefaultRegistrationFinishService
|
||||
implements RegistrationFinishService
|
||||
@@ -27,17 +30,17 @@ export class WebRegistrationFinishService
|
||||
constructor(
|
||||
protected keyService: KeyService,
|
||||
protected accountApiService: AccountApiService,
|
||||
private acceptOrgInviteService: AcceptOrganizationInviteService,
|
||||
private organizationInviteService: OrganizationInviteService,
|
||||
private policyApiService: PolicyApiServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private policyService: PolicyService,
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super(keyService, accountApiService);
|
||||
}
|
||||
|
||||
override async getOrgNameFromOrgInvite(): Promise<string | null> {
|
||||
const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite();
|
||||
const orgInvite = await this.organizationInviteService.getOrganizationInvite();
|
||||
if (orgInvite == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -47,7 +50,7 @@ export class WebRegistrationFinishService
|
||||
|
||||
override async getMasterPasswordPolicyOptsFromOrgInvite(): Promise<MasterPasswordPolicyOptions | null> {
|
||||
// If there's a deep linked org invite, use it to get the password policies
|
||||
const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite();
|
||||
const orgInvite = await this.organizationInviteService.getOrganizationInvite();
|
||||
|
||||
if (orgInvite == null) {
|
||||
return null;
|
||||
@@ -76,6 +79,18 @@ export class WebRegistrationFinishService
|
||||
return masterPasswordPolicyOpts;
|
||||
}
|
||||
|
||||
override async determineLoginSuccessRoute(): Promise<string> {
|
||||
const endUserActivationFlagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM19315EndUserActivationMvp,
|
||||
);
|
||||
|
||||
if (endUserActivationFlagEnabled) {
|
||||
return "/setup-extension";
|
||||
} else {
|
||||
return super.determineLoginSuccessRoute();
|
||||
}
|
||||
}
|
||||
|
||||
// Note: the org invite token and email verification are mutually exclusive. Only one will be present.
|
||||
override async buildRegisterRequest(
|
||||
email: string,
|
||||
@@ -100,7 +115,7 @@ export class WebRegistrationFinishService
|
||||
// web specific logic
|
||||
// Org invites are deep linked. Non-existent accounts are redirected to the register page.
|
||||
// Org user id and token are included here only for validation and two factor purposes.
|
||||
const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite();
|
||||
const orgInvite = await this.organizationInviteService.getOrganizationInvite();
|
||||
if (orgInvite != null) {
|
||||
registerRequest.organizationUserId = orgInvite.organizationUserId;
|
||||
registerRequest.orgInviteToken = orgInvite.token;
|
||||
|
||||
@@ -5,16 +5,16 @@ import {
|
||||
SetPasswordCredentials,
|
||||
SetPasswordJitService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
|
||||
import { RouterService } from "../../../../core/router.service";
|
||||
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
|
||||
|
||||
export class WebSetPasswordJitService
|
||||
extends DefaultSetPasswordJitService
|
||||
implements SetPasswordJitService
|
||||
{
|
||||
routerService = inject(RouterService);
|
||||
acceptOrganizationInviteService = inject(AcceptOrganizationInviteService);
|
||||
organizationInviteService = inject(OrganizationInviteService);
|
||||
|
||||
override async setPassword(credentials: SetPasswordCredentials) {
|
||||
await super.setPassword(credentials);
|
||||
@@ -22,6 +22,6 @@ export class WebSetPasswordJitService
|
||||
// SSO JIT accepts org invites when setting their MP, meaning
|
||||
// we can clear the deep linked url for accepting it.
|
||||
await this.routerService.getAndClearLoginRedirectUrl();
|
||||
await this.acceptOrganizationInviteService.clearOrganizationInvitation();
|
||||
await this.organizationInviteService.clearOrganizationInvitation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { RotateableKeySet } from "@bitwarden/auth/common";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
|
||||
import { WebauthnLoginCredentialPrfStatus } from "../../../enums/webauthn-login-credential-prf-status.enum";
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
|
||||
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
|
||||
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
||||
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { makeSymmetricCryptoKey } from "@bitwarden/common/spec";
|
||||
import { PrfKey, UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ import { KdfType } from "@bitwarden/key-management";
|
||||
|
||||
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
|
||||
import { EmergencyAccessType } from "../enums/emergency-access-type";
|
||||
import {
|
||||
EmergencyAccessGranteeDetailsResponse,
|
||||
EmergencyAccessGrantorDetailsResponse,
|
||||
} from "../response/emergency-access.response";
|
||||
|
||||
export class GranteeEmergencyAccess {
|
||||
id: string;
|
||||
@@ -16,6 +20,24 @@ export class GranteeEmergencyAccess {
|
||||
waitTimeDays: number;
|
||||
creationDate: string;
|
||||
avatarColor: string;
|
||||
|
||||
constructor(partial: Partial<GranteeEmergencyAccess> = {}) {
|
||||
Object.assign(this, partial);
|
||||
}
|
||||
|
||||
static fromResponse(response: EmergencyAccessGranteeDetailsResponse) {
|
||||
return new GranteeEmergencyAccess({
|
||||
id: response.id,
|
||||
granteeId: response.granteeId,
|
||||
name: response.name,
|
||||
email: response.email,
|
||||
type: response.type,
|
||||
status: response.status,
|
||||
waitTimeDays: response.waitTimeDays,
|
||||
creationDate: response.creationDate,
|
||||
avatarColor: response.avatarColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class GrantorEmergencyAccess {
|
||||
@@ -28,6 +50,24 @@ export class GrantorEmergencyAccess {
|
||||
waitTimeDays: number;
|
||||
creationDate: string;
|
||||
avatarColor: string;
|
||||
|
||||
constructor(partial: Partial<GrantorEmergencyAccess> = {}) {
|
||||
Object.assign(this, partial);
|
||||
}
|
||||
|
||||
static fromResponse(response: EmergencyAccessGrantorDetailsResponse) {
|
||||
return new GrantorEmergencyAccess({
|
||||
id: response.id,
|
||||
grantorId: response.grantorId,
|
||||
name: response.name,
|
||||
email: response.email,
|
||||
type: response.type,
|
||||
status: response.status,
|
||||
waitTimeDays: response.waitTimeDays,
|
||||
creationDate: response.creationDate,
|
||||
avatarColor: response.avatarColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class TakeoverTypeEmergencyAccess {
|
||||
|
||||
@@ -6,13 +6,13 @@ import mock from "jest-mock-extended/lib/Mock";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -22,9 +22,11 @@ import { KdfType, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
|
||||
import { EmergencyAccessType } from "../enums/emergency-access-type";
|
||||
import { GranteeEmergencyAccess, GrantorEmergencyAccess } from "../models/emergency-access";
|
||||
import { EmergencyAccessPasswordRequest } from "../request/emergency-access-password.request";
|
||||
import {
|
||||
EmergencyAccessGranteeDetailsResponse,
|
||||
EmergencyAccessGrantorDetailsResponse,
|
||||
EmergencyAccessTakeoverResponse,
|
||||
} from "../response/emergency-access.response";
|
||||
|
||||
@@ -242,11 +244,19 @@ describe("EmergencyAccessService", () => {
|
||||
|
||||
const mockEmergencyAccess = {
|
||||
data: [
|
||||
createMockEmergencyAccess("0", "EA 0", EmergencyAccessStatusType.Invited),
|
||||
createMockEmergencyAccess("1", "EA 1", EmergencyAccessStatusType.Accepted),
|
||||
createMockEmergencyAccess("2", "EA 2", EmergencyAccessStatusType.Confirmed),
|
||||
createMockEmergencyAccess("3", "EA 3", EmergencyAccessStatusType.RecoveryInitiated),
|
||||
createMockEmergencyAccess("4", "EA 4", EmergencyAccessStatusType.RecoveryApproved),
|
||||
createMockEmergencyAccessGranteeDetails("0", "EA 0", EmergencyAccessStatusType.Invited),
|
||||
createMockEmergencyAccessGranteeDetails("1", "EA 1", EmergencyAccessStatusType.Accepted),
|
||||
createMockEmergencyAccessGranteeDetails("2", "EA 2", EmergencyAccessStatusType.Confirmed),
|
||||
createMockEmergencyAccessGranteeDetails(
|
||||
"3",
|
||||
"EA 3",
|
||||
EmergencyAccessStatusType.RecoveryInitiated,
|
||||
),
|
||||
createMockEmergencyAccessGranteeDetails(
|
||||
"4",
|
||||
"EA 4",
|
||||
EmergencyAccessStatusType.RecoveryApproved,
|
||||
),
|
||||
],
|
||||
} as ListResponse<EmergencyAccessGranteeDetailsResponse>;
|
||||
|
||||
@@ -295,9 +305,113 @@ describe("EmergencyAccessService", () => {
|
||||
).rejects.toThrow("New user key is required for rotation.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmergencyAccessTrusted", () => {
|
||||
it("should return an empty array if no emergency access is granted", async () => {
|
||||
emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue({
|
||||
data: [],
|
||||
} as ListResponse<EmergencyAccessGranteeDetailsResponse>);
|
||||
|
||||
const result = await emergencyAccessService.getEmergencyAccessTrusted();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return an empty array if the API returns an empty response", async () => {
|
||||
emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue(
|
||||
null as unknown as ListResponse<EmergencyAccessGranteeDetailsResponse>,
|
||||
);
|
||||
|
||||
const result = await emergencyAccessService.getEmergencyAccessTrusted();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return a list of trusted emergency access contacts", async () => {
|
||||
const mockEmergencyAccess = [
|
||||
createMockEmergencyAccessGranteeDetails("1", "EA 1", EmergencyAccessStatusType.Invited),
|
||||
createMockEmergencyAccessGranteeDetails("2", "EA 2", EmergencyAccessStatusType.Invited),
|
||||
createMockEmergencyAccessGranteeDetails("3", "EA 3", EmergencyAccessStatusType.Accepted),
|
||||
createMockEmergencyAccessGranteeDetails("4", "EA 4", EmergencyAccessStatusType.Confirmed),
|
||||
createMockEmergencyAccessGranteeDetails(
|
||||
"5",
|
||||
"EA 5",
|
||||
EmergencyAccessStatusType.RecoveryInitiated,
|
||||
),
|
||||
];
|
||||
emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue({
|
||||
data: mockEmergencyAccess,
|
||||
} as ListResponse<EmergencyAccessGranteeDetailsResponse>);
|
||||
|
||||
const result = await emergencyAccessService.getEmergencyAccessTrusted();
|
||||
|
||||
expect(result).toHaveLength(mockEmergencyAccess.length);
|
||||
|
||||
result.forEach((access, index) => {
|
||||
expect(access).toBeInstanceOf(GranteeEmergencyAccess);
|
||||
|
||||
expect(access.id).toBe(mockEmergencyAccess[index].id);
|
||||
expect(access.name).toBe(mockEmergencyAccess[index].name);
|
||||
expect(access.status).toBe(mockEmergencyAccess[index].status);
|
||||
expect(access.type).toBe(mockEmergencyAccess[index].type);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmergencyAccessGranted", () => {
|
||||
it("should return an empty array if no emergency access is granted", async () => {
|
||||
emergencyAccessApiService.getEmergencyAccessGranted.mockResolvedValue({
|
||||
data: [],
|
||||
} as ListResponse<EmergencyAccessGrantorDetailsResponse>);
|
||||
|
||||
const result = await emergencyAccessService.getEmergencyAccessGranted();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return an empty array if the API returns an empty response", async () => {
|
||||
emergencyAccessApiService.getEmergencyAccessGranted.mockResolvedValue(
|
||||
null as unknown as ListResponse<EmergencyAccessGrantorDetailsResponse>,
|
||||
);
|
||||
|
||||
const result = await emergencyAccessService.getEmergencyAccessGranted();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return a list of granted emergency access contacts", async () => {
|
||||
const mockEmergencyAccess = [
|
||||
createMockEmergencyAccessGrantorDetails("1", "EA 1", EmergencyAccessStatusType.Invited),
|
||||
createMockEmergencyAccessGrantorDetails("2", "EA 2", EmergencyAccessStatusType.Invited),
|
||||
createMockEmergencyAccessGrantorDetails("3", "EA 3", EmergencyAccessStatusType.Accepted),
|
||||
createMockEmergencyAccessGrantorDetails("4", "EA 4", EmergencyAccessStatusType.Confirmed),
|
||||
createMockEmergencyAccessGrantorDetails(
|
||||
"5",
|
||||
"EA 5",
|
||||
EmergencyAccessStatusType.RecoveryInitiated,
|
||||
),
|
||||
];
|
||||
emergencyAccessApiService.getEmergencyAccessGranted.mockResolvedValue({
|
||||
data: mockEmergencyAccess,
|
||||
} as ListResponse<EmergencyAccessGrantorDetailsResponse>);
|
||||
|
||||
const result = await emergencyAccessService.getEmergencyAccessGranted();
|
||||
|
||||
expect(result).toHaveLength(mockEmergencyAccess.length);
|
||||
|
||||
result.forEach((access, index) => {
|
||||
expect(access).toBeInstanceOf(GrantorEmergencyAccess);
|
||||
|
||||
expect(access.id).toBe(mockEmergencyAccess[index].id);
|
||||
expect(access.name).toBe(mockEmergencyAccess[index].name);
|
||||
expect(access.status).toBe(mockEmergencyAccess[index].status);
|
||||
expect(access.type).toBe(mockEmergencyAccess[index].type);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createMockEmergencyAccess(
|
||||
function createMockEmergencyAccessGranteeDetails(
|
||||
id: string,
|
||||
name: string,
|
||||
status: EmergencyAccessStatusType,
|
||||
@@ -309,3 +423,16 @@ function createMockEmergencyAccess(
|
||||
emergencyAccess.status = status;
|
||||
return emergencyAccess;
|
||||
}
|
||||
|
||||
function createMockEmergencyAccessGrantorDetails(
|
||||
id: string,
|
||||
name: string,
|
||||
status: EmergencyAccessStatusType,
|
||||
): EmergencyAccessGrantorDetailsResponse {
|
||||
const emergencyAccess = new EmergencyAccessGrantorDetailsResponse({});
|
||||
emergencyAccess.id = id;
|
||||
emergencyAccess.name = name;
|
||||
emergencyAccess.type = 0;
|
||||
emergencyAccess.status = status;
|
||||
return emergencyAccess;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import {
|
||||
EncryptedString,
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
@@ -74,14 +77,22 @@ export class EmergencyAccessService
|
||||
* Gets all emergency access that the user has been granted.
|
||||
*/
|
||||
async getEmergencyAccessTrusted(): Promise<GranteeEmergencyAccess[]> {
|
||||
return (await this.emergencyAccessApiService.getEmergencyAccessTrusted()).data;
|
||||
const listResponse = await this.emergencyAccessApiService.getEmergencyAccessTrusted();
|
||||
if (!listResponse || listResponse.data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return listResponse.data.map((response) => GranteeEmergencyAccess.fromResponse(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all emergency access that the user has granted.
|
||||
*/
|
||||
async getEmergencyAccessGranted(): Promise<GrantorEmergencyAccess[]> {
|
||||
return (await this.emergencyAccessApiService.getEmergencyAccessGranted()).data;
|
||||
const listResponse = await this.emergencyAccessApiService.getEmergencyAccessGranted();
|
||||
if (!listResponse || listResponse.data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return listResponse.data.map((response) => GrantorEmergencyAccess.fromResponse(response));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,13 +4,14 @@ import { Component } from "@angular/core";
|
||||
import { ActivatedRoute, Params, Router } from "@angular/router";
|
||||
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { BaseAcceptComponent } from "../../common/base.accept.component";
|
||||
|
||||
import { AcceptOrganizationInviteService } from "./accept-organization.service";
|
||||
import { OrganizationInvite } from "./organization-invite";
|
||||
|
||||
@Component({
|
||||
templateUrl: "accept-organization.component.html",
|
||||
@@ -21,18 +22,19 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent {
|
||||
protected requiredParameters: string[] = ["organizationId", "organizationUserId", "token"];
|
||||
|
||||
constructor(
|
||||
router: Router,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
i18nService: I18nService,
|
||||
route: ActivatedRoute,
|
||||
authService: AuthService,
|
||||
protected router: Router,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected i18nService: I18nService,
|
||||
protected route: ActivatedRoute,
|
||||
protected authService: AuthService,
|
||||
private acceptOrganizationInviteService: AcceptOrganizationInviteService,
|
||||
private organizationInviteService: OrganizationInviteService,
|
||||
) {
|
||||
super(router, platformUtilsService, i18nService, route, authService);
|
||||
}
|
||||
|
||||
async authedHandler(qParams: Params): Promise<void> {
|
||||
const invite = OrganizationInvite.fromParams(qParams);
|
||||
const invite = this.fromParams(qParams);
|
||||
const success = await this.acceptOrganizationInviteService.validateAndAcceptInvite(invite);
|
||||
|
||||
if (!success) {
|
||||
@@ -52,9 +54,9 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent {
|
||||
}
|
||||
|
||||
async unauthedHandler(qParams: Params): Promise<void> {
|
||||
const invite = OrganizationInvite.fromParams(qParams);
|
||||
const invite = this.fromParams(qParams);
|
||||
|
||||
await this.acceptOrganizationInviteService.setOrganizationInvitation(invite);
|
||||
await this.organizationInviteService.setOrganizationInvitation(invite);
|
||||
await this.navigateInviteAcceptance(invite);
|
||||
}
|
||||
|
||||
@@ -94,4 +96,21 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
private fromParams(params: Params): OrganizationInvite | null {
|
||||
if (params == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new OrganizationInvite(), {
|
||||
email: params.email,
|
||||
initOrganization: params.initOrganization?.toLocaleLowerCase() === "true",
|
||||
orgSsoIdentifier: params.orgSsoIdentifier,
|
||||
orgUserHasExistingUser: params.orgUserHasExistingUser?.toLocaleLowerCase() === "true",
|
||||
organizationId: params.organizationId,
|
||||
organizationName: params.organizationName,
|
||||
organizationUserId: params.organizationUserId,
|
||||
token: params.token,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { FakeGlobalStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
@@ -15,22 +14,18 @@ import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/mode
|
||||
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { FakeGlobalState } from "@bitwarden/common/spec/fake-state";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { I18nService } from "../../core/i18n.service";
|
||||
|
||||
import {
|
||||
AcceptOrganizationInviteService,
|
||||
ORGANIZATION_INVITE,
|
||||
} from "./accept-organization.service";
|
||||
import { OrganizationInvite } from "./organization-invite";
|
||||
import { AcceptOrganizationInviteService } from "./accept-organization.service";
|
||||
|
||||
describe("AcceptOrganizationInviteService", () => {
|
||||
let sut: AcceptOrganizationInviteService;
|
||||
@@ -43,10 +38,8 @@ describe("AcceptOrganizationInviteService", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
|
||||
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
|
||||
let organizationInviteService: MockProxy<OrganizationInviteService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let globalStateProvider: FakeGlobalStateProvider;
|
||||
let globalState: FakeGlobalState<OrganizationInvite>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -59,10 +52,8 @@ describe("AcceptOrganizationInviteService", () => {
|
||||
logService = mock();
|
||||
organizationApiService = mock();
|
||||
organizationUserApiService = mock();
|
||||
organizationInviteService = mock();
|
||||
i18nService = mock();
|
||||
globalStateProvider = new FakeGlobalStateProvider();
|
||||
globalState = globalStateProvider.getFake(ORGANIZATION_INVITE);
|
||||
dialogService = mock();
|
||||
accountService = mock();
|
||||
|
||||
sut = new AcceptOrganizationInviteService(
|
||||
@@ -76,8 +67,7 @@ describe("AcceptOrganizationInviteService", () => {
|
||||
organizationApiService,
|
||||
organizationUserApiService,
|
||||
i18nService,
|
||||
globalStateProvider,
|
||||
dialogService,
|
||||
organizationInviteService,
|
||||
accountService,
|
||||
);
|
||||
});
|
||||
@@ -103,8 +93,10 @@ describe("AcceptOrganizationInviteService", () => {
|
||||
expect(result).toBe(true);
|
||||
expect(organizationUserApiService.postOrganizationUserAcceptInit).toHaveBeenCalled();
|
||||
expect(apiService.refreshIdentityToken).toHaveBeenCalled();
|
||||
expect(globalState.nextMock).toHaveBeenCalledWith(null);
|
||||
expect(organizationUserApiService.postOrganizationUserAccept).not.toHaveBeenCalled();
|
||||
expect(organizationInviteService.getOrganizationInvite).not.toHaveBeenCalled();
|
||||
expect(organizationInviteService.setOrganizationInvitation).not.toHaveBeenCalled();
|
||||
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalled();
|
||||
expect(authService.logOut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -121,13 +113,16 @@ describe("AcceptOrganizationInviteService", () => {
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(authService.logOut).toHaveBeenCalled();
|
||||
expect(globalState.nextMock).toHaveBeenCalledWith(invite);
|
||||
expect(organizationInviteService.setOrganizationInvitation).toHaveBeenCalledWith(invite);
|
||||
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the stored invite when a master password policy check is required but the stored invite doesn't match the provided one", async () => {
|
||||
const storedInvite = createOrgInvite({ email: "wrongemail@example.com" });
|
||||
const providedInvite = createOrgInvite();
|
||||
await globalState.update(() => storedInvite);
|
||||
organizationInviteService.getOrganizationInvite.mockReturnValueOnce(
|
||||
Promise.resolve(storedInvite),
|
||||
);
|
||||
policyApiService.getPoliciesByToken.mockResolvedValue([
|
||||
{
|
||||
type: PolicyType.MasterPassword,
|
||||
@@ -139,7 +134,11 @@ describe("AcceptOrganizationInviteService", () => {
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(authService.logOut).toHaveBeenCalled();
|
||||
expect(globalState.nextMock).toHaveBeenCalledWith(providedInvite);
|
||||
expect(organizationInviteService.setOrganizationInvitation).toHaveBeenCalledWith(
|
||||
providedInvite,
|
||||
);
|
||||
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalledWith();
|
||||
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts the invitation request when the organization doesn't have a master password policy", async () => {
|
||||
@@ -151,8 +150,10 @@ describe("AcceptOrganizationInviteService", () => {
|
||||
expect(result).toBe(true);
|
||||
expect(organizationUserApiService.postOrganizationUserAccept).toHaveBeenCalled();
|
||||
expect(apiService.refreshIdentityToken).toHaveBeenCalled();
|
||||
expect(globalState.nextMock).toHaveBeenCalledWith(null);
|
||||
expect(organizationUserApiService.postOrganizationUserAcceptInit).not.toHaveBeenCalled();
|
||||
expect(organizationInviteService.setOrganizationInvitation).not.toHaveBeenCalled();
|
||||
expect(organizationInviteService.getOrganizationInvite).not.toHaveBeenCalled();
|
||||
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalled();
|
||||
expect(authService.logOut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -165,7 +166,7 @@ describe("AcceptOrganizationInviteService", () => {
|
||||
} as Policy,
|
||||
]);
|
||||
// an existing invite means the user has already passed the master password policy
|
||||
await globalState.update(() => invite);
|
||||
organizationInviteService.getOrganizationInvite.mockReturnValueOnce(Promise.resolve(invite));
|
||||
|
||||
policyService.getResetPasswordPolicyOptions.mockReturnValue([
|
||||
{
|
||||
@@ -179,6 +180,8 @@ describe("AcceptOrganizationInviteService", () => {
|
||||
expect(result).toBe(true);
|
||||
expect(organizationUserApiService.postOrganizationUserAccept).toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserAcceptInit).not.toHaveBeenCalled();
|
||||
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalledWith();
|
||||
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalled();
|
||||
expect(authService.logOut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -202,7 +205,7 @@ describe("AcceptOrganizationInviteService", () => {
|
||||
encryptedString: "encryptedString",
|
||||
} as EncString);
|
||||
|
||||
await globalState.update(() => invite);
|
||||
organizationInviteService.getOrganizationInvite.mockReturnValueOnce(Promise.resolve(invite));
|
||||
|
||||
policyService.getResetPasswordPolicyOptions.mockReturnValue([
|
||||
{
|
||||
@@ -220,6 +223,9 @@ describe("AcceptOrganizationInviteService", () => {
|
||||
);
|
||||
expect(organizationUserApiService.postOrganizationUserAccept).toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserAcceptInit).not.toHaveBeenCalled();
|
||||
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalledTimes(1);
|
||||
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalledWith();
|
||||
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalled();
|
||||
expect(authService.logOut).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,36 +17,17 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
GlobalState,
|
||||
GlobalStateProvider,
|
||||
KeyDefinition,
|
||||
ORGANIZATION_INVITE_DISK,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationInvite } from "./organization-invite";
|
||||
|
||||
// We're storing the organization invite for 2 reasons:
|
||||
// 1. If the org requires a MP policy check, we need to keep track that the user has already been redirected when they return.
|
||||
// 2. The MP policy check happens on login/register flows, we need to store the token to retrieve the policies then.
|
||||
export const ORGANIZATION_INVITE = new KeyDefinition<OrganizationInvite | null>(
|
||||
ORGANIZATION_INVITE_DISK,
|
||||
"organizationInvite",
|
||||
{
|
||||
deserializer: (invite) => (invite ? OrganizationInvite.fromJSON(invite) : null),
|
||||
},
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
export class AcceptOrganizationInviteService {
|
||||
private organizationInvitationState: GlobalState<OrganizationInvite | null>;
|
||||
private orgNameSubject: BehaviorSubject<string> = new BehaviorSubject<string>(null);
|
||||
private policyCache: Policy[];
|
||||
|
||||
@@ -64,34 +45,9 @@ export class AcceptOrganizationInviteService {
|
||||
private readonly organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private readonly organizationUserApiService: OrganizationUserApiService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly globalStateProvider: GlobalStateProvider,
|
||||
private readonly dialogService: DialogService,
|
||||
private readonly organizationInviteService: OrganizationInviteService,
|
||||
private readonly accountService: AccountService,
|
||||
) {
|
||||
this.organizationInvitationState = this.globalStateProvider.get(ORGANIZATION_INVITE);
|
||||
}
|
||||
|
||||
/** Returns the currently stored organization invite */
|
||||
async getOrganizationInvite(): Promise<OrganizationInvite | null> {
|
||||
return await firstValueFrom(this.organizationInvitationState.state$);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a new organization invite
|
||||
* @param invite an organization invite
|
||||
* @throws if the invite is nullish
|
||||
*/
|
||||
async setOrganizationInvitation(invite: OrganizationInvite): Promise<void> {
|
||||
if (invite == null) {
|
||||
throw new Error("Invite cannot be null. Use clearOrganizationInvitation instead.");
|
||||
}
|
||||
await this.organizationInvitationState.update(() => invite);
|
||||
}
|
||||
|
||||
/** Clears the currently stored organization invite */
|
||||
async clearOrganizationInvitation(): Promise<void> {
|
||||
await this.organizationInvitationState.update(() => null);
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validates and accepts the organization invitation if possible.
|
||||
@@ -113,7 +69,7 @@ export class AcceptOrganizationInviteService {
|
||||
|
||||
// Accepting an org invite from existing org
|
||||
if (await this.masterPasswordPolicyCheckRequired(invite)) {
|
||||
await this.setOrganizationInvitation(invite);
|
||||
await this.organizationInviteService.setOrganizationInvitation(invite);
|
||||
this.authService.logOut(() => {
|
||||
/* Do nothing */
|
||||
});
|
||||
@@ -134,7 +90,7 @@ export class AcceptOrganizationInviteService {
|
||||
),
|
||||
);
|
||||
await this.apiService.refreshIdentityToken();
|
||||
await this.clearOrganizationInvitation();
|
||||
await this.organizationInviteService.clearOrganizationInvitation();
|
||||
}
|
||||
|
||||
private async prepareAcceptAndInitRequest(
|
||||
@@ -170,7 +126,7 @@ export class AcceptOrganizationInviteService {
|
||||
);
|
||||
|
||||
await this.apiService.refreshIdentityToken();
|
||||
await this.clearOrganizationInvitation();
|
||||
await this.organizationInviteService.clearOrganizationInvitation();
|
||||
}
|
||||
|
||||
private async prepareAcceptRequest(
|
||||
@@ -224,10 +180,10 @@ export class AcceptOrganizationInviteService {
|
||||
(p) => p.type === PolicyType.MasterPassword && p.enabled,
|
||||
);
|
||||
|
||||
let storedInvite = await this.getOrganizationInvite();
|
||||
let storedInvite = await this.organizationInviteService.getOrganizationInvite();
|
||||
if (storedInvite?.email !== invite.email) {
|
||||
// clear stored invites if the email doesn't match
|
||||
await this.clearOrganizationInvitation();
|
||||
await this.organizationInviteService.clearOrganizationInvitation();
|
||||
storedInvite = null;
|
||||
}
|
||||
// if we don't have an org invite stored, we know the user hasn't been redirected yet to check the MP policy
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Params } from "@angular/router";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
export class OrganizationInvite {
|
||||
email: string;
|
||||
initOrganization: boolean;
|
||||
orgSsoIdentifier: string;
|
||||
orgUserHasExistingUser: boolean;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
organizationUserId: string;
|
||||
token: string;
|
||||
|
||||
static fromJSON(json: Jsonify<OrganizationInvite>): OrganizationInvite | null {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new OrganizationInvite(), json);
|
||||
}
|
||||
|
||||
static fromParams(params: Params): OrganizationInvite | null {
|
||||
if (params == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new OrganizationInvite(), {
|
||||
email: params.email,
|
||||
initOrganization: params.initOrganization?.toLocaleLowerCase() === "true",
|
||||
orgSsoIdentifier: params.orgSsoIdentifier,
|
||||
orgUserHasExistingUser: params.orgUserHasExistingUser?.toLocaleLowerCase() === "true",
|
||||
organizationId: params.organizationId,
|
||||
organizationName: params.organizationName,
|
||||
organizationUserId: params.organizationUserId,
|
||||
token: params.token,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Component, inject } from "@angular/core";
|
||||
|
||||
import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { RouterService } from "../core";
|
||||
|
||||
import { AcceptOrganizationInviteService } from "./organization-invite/accept-organization.service";
|
||||
|
||||
@Component({
|
||||
selector: "app-set-password",
|
||||
templateUrl: "set-password.component.html",
|
||||
@@ -15,7 +14,7 @@ import { AcceptOrganizationInviteService } from "./organization-invite/accept-or
|
||||
})
|
||||
export class SetPasswordComponent extends BaseSetPasswordComponent {
|
||||
routerService = inject(RouterService);
|
||||
acceptOrganizationInviteService = inject(AcceptOrganizationInviteService);
|
||||
organizationInviteService = inject(OrganizationInviteService);
|
||||
|
||||
protected override async onSetPasswordSuccess(
|
||||
masterKey: MasterKey,
|
||||
@@ -26,6 +25,6 @@ export class SetPasswordComponent extends BaseSetPasswordComponent {
|
||||
// SSO JIT accepts org invites when setting their MP, meaning
|
||||
// we can clear the deep linked url for accepting it.
|
||||
await this.routerService.getAndClearLoginRedirectUrl();
|
||||
await this.acceptOrganizationInviteService.clearOrganizationInvitation();
|
||||
await this.organizationInviteService.clearOrganizationInvitation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,34 +43,34 @@ export class ChangePasswordComponent
|
||||
characterMinimumMessage = "";
|
||||
|
||||
constructor(
|
||||
i18nService: I18nService,
|
||||
keyService: KeyService,
|
||||
messagingService: MessagingService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
policyService: PolicyService,
|
||||
private auditService: AuditService,
|
||||
private cipherService: CipherService,
|
||||
private syncService: SyncService,
|
||||
private keyRotationService: UserKeyRotationService,
|
||||
private masterPasswordApiService: MasterPasswordApiService,
|
||||
private router: Router,
|
||||
dialogService: DialogService,
|
||||
private syncService: SyncService,
|
||||
private userVerificationService: UserVerificationService,
|
||||
private keyRotationService: UserKeyRotationService,
|
||||
kdfConfigService: KdfConfigService,
|
||||
protected accountService: AccountService,
|
||||
protected dialogService: DialogService,
|
||||
protected i18nService: I18nService,
|
||||
protected kdfConfigService: KdfConfigService,
|
||||
protected keyService: KeyService,
|
||||
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
accountService: AccountService,
|
||||
toastService: ToastService,
|
||||
protected messagingService: MessagingService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected policyService: PolicyService,
|
||||
protected toastService: ToastService,
|
||||
) {
|
||||
super(
|
||||
accountService,
|
||||
dialogService,
|
||||
i18nService,
|
||||
kdfConfigService,
|
||||
keyService,
|
||||
masterPasswordService,
|
||||
messagingService,
|
||||
platformUtilsService,
|
||||
policyService,
|
||||
dialogService,
|
||||
kdfConfigService,
|
||||
masterPasswordService,
|
||||
accountService,
|
||||
toastService,
|
||||
);
|
||||
}
|
||||
@@ -244,8 +244,7 @@ export class ChangePasswordComponent
|
||||
await this.masterPasswordApiService.postPassword(request);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: this.i18nService.t("masterPasswordChanged"),
|
||||
message: this.i18nService.t("masterPasswordChangedDesc"),
|
||||
message: this.i18nService.t("masterPasswordChanged"),
|
||||
});
|
||||
this.messagingService.send("logout");
|
||||
} catch {
|
||||
|
||||
@@ -71,15 +71,15 @@ export class EmergencyAccessTakeoverComponent
|
||||
protected toastService: ToastService,
|
||||
) {
|
||||
super(
|
||||
accountService,
|
||||
dialogService,
|
||||
i18nService,
|
||||
kdfConfigService,
|
||||
keyService,
|
||||
masterPasswordService,
|
||||
messagingService,
|
||||
platformUtilsService,
|
||||
policyService,
|
||||
dialogService,
|
||||
kdfConfigService,
|
||||
masterPasswordService,
|
||||
accountService,
|
||||
toastService,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { VaultBannersService } from "../../../vault/individual-vault/vault-banners/services/vault-banners.service";
|
||||
|
||||
import { DeviceManagementComponent } from "./device-management.component";
|
||||
import { DeviceManagementOldComponent } from "./device-management-old.component";
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = jest.fn();
|
||||
@@ -35,8 +35,8 @@ interface Message {
|
||||
notificationId?: string;
|
||||
}
|
||||
|
||||
describe("DeviceManagementComponent", () => {
|
||||
let fixture: ComponentFixture<DeviceManagementComponent>;
|
||||
describe("DeviceManagementOldComponent", () => {
|
||||
let fixture: ComponentFixture<DeviceManagementOldComponent>;
|
||||
let messageSubject: Subject<Message>;
|
||||
let mockDevices: DeviceView[];
|
||||
let vaultBannersService: VaultBannersService;
|
||||
@@ -66,7 +66,7 @@ describe("DeviceManagementComponent", () => {
|
||||
SharedModule,
|
||||
TableModule,
|
||||
PopoverModule,
|
||||
DeviceManagementComponent,
|
||||
DeviceManagementOldComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -130,7 +130,7 @@ describe("DeviceManagementComponent", () => {
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DeviceManagementComponent);
|
||||
fixture = TestBed.createComponent(DeviceManagementOldComponent);
|
||||
|
||||
vaultBannersService = TestBed.inject(VaultBannersService);
|
||||
});
|
||||
@@ -45,10 +45,10 @@ interface DeviceTableData {
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-device-management",
|
||||
templateUrl: "./device-management.component.html",
|
||||
templateUrl: "./device-management-old.component.html",
|
||||
imports: [CommonModule, SharedModule, TableModule, PopoverModule],
|
||||
})
|
||||
export class DeviceManagementComponent {
|
||||
export class DeviceManagementOldComponent {
|
||||
protected dataSource = new TableDataSource<DeviceTableData>();
|
||||
protected currentDevice: DeviceView | undefined;
|
||||
protected loading = true;
|
||||
@@ -1,7 +1,6 @@
|
||||
<h1 class="tw-mt-6 tw-mb-2 tw-pb-2.5">{{ "changeMasterPassword" | i18n }}</h1>
|
||||
|
||||
<div class="tw-max-w-lg tw-mb-12">
|
||||
<bit-callout type="warning">{{ "loggedOutWarning" | i18n }}</bit-callout>
|
||||
<auth-change-password [inputPasswordFlow]="inputPasswordFlow"></auth-change-password>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Component, OnInit } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ChangePasswordComponent, InputPasswordFlow } from "@bitwarden/auth/angular";
|
||||
import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password";
|
||||
import { InputPasswordFlow } from "@bitwarden/auth/angular";
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { CalloutModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
@@ -16,6 +17,7 @@ import { WebauthnLoginSettingsModule } from "../../webauthn-login-settings";
|
||||
})
|
||||
export class PasswordSettingsComponent implements OnInit {
|
||||
inputPasswordFlow = InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation;
|
||||
changePasswordFeatureFlag = false;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
import { ChangePasswordComponent } from "../change-password.component";
|
||||
import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component";
|
||||
|
||||
import { DeviceManagementComponent } from "./device-management.component";
|
||||
import { DeviceManagementOldComponent } from "./device-management-old.component";
|
||||
import { PasswordSettingsComponent } from "./password-settings/password-settings.component";
|
||||
import { SecurityKeysComponent } from "./security-keys.component";
|
||||
import { SecurityComponent } from "./security.component";
|
||||
@@ -55,11 +57,15 @@ const routes: Routes = [
|
||||
component: SecurityKeysComponent,
|
||||
data: { titleId: "keys" },
|
||||
},
|
||||
{
|
||||
path: "device-management",
|
||||
component: DeviceManagementComponent,
|
||||
data: { titleId: "devices" },
|
||||
},
|
||||
...featureFlaggedRoute({
|
||||
defaultComponent: DeviceManagementOldComponent,
|
||||
flaggedComponent: DeviceManagementComponent,
|
||||
featureFlag: FeatureFlag.PM14938_BrowserExtensionLoginApproval,
|
||||
routeOptions: {
|
||||
path: "device-management",
|
||||
data: { titleId: "devices" },
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Component, inject } from "@angular/core";
|
||||
|
||||
import { UpdatePasswordComponent as BaseUpdatePasswordComponent } from "@bitwarden/angular/auth/components/update-password.component";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
|
||||
import { RouterService } from "../core";
|
||||
|
||||
import { AcceptOrganizationInviteService } from "./organization-invite/accept-organization.service";
|
||||
|
||||
@Component({
|
||||
selector: "app-update-password",
|
||||
templateUrl: "update-password.component.html",
|
||||
@@ -13,13 +12,13 @@ import { AcceptOrganizationInviteService } from "./organization-invite/accept-or
|
||||
})
|
||||
export class UpdatePasswordComponent extends BaseUpdatePasswordComponent {
|
||||
private routerService = inject(RouterService);
|
||||
private acceptOrganizationInviteService = inject(AcceptOrganizationInviteService);
|
||||
private organizationInviteService = inject(OrganizationInviteService);
|
||||
|
||||
override async cancel() {
|
||||
// clearing the login redirect url so that the user
|
||||
// does not join the organization if they cancel
|
||||
await this.routerService.getAndClearLoginRedirectUrl();
|
||||
await this.acceptOrganizationInviteService.clearOrganizationInvitation();
|
||||
await this.organizationInviteService.clearOrganizationInvitation();
|
||||
await super.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
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";
|
||||
@@ -30,6 +32,11 @@ const routes: Routes = [
|
||||
component: PaymentMethodComponent,
|
||||
data: { titleId: "paymentMethod" },
|
||||
},
|
||||
{
|
||||
path: "payment-details",
|
||||
component: AccountPaymentDetailsComponent,
|
||||
data: { titleId: "paymentDetails" },
|
||||
},
|
||||
{
|
||||
path: "billing-history",
|
||||
component: BillingHistoryViewComponent,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<bit-container>
|
||||
@let view = view$ | async;
|
||||
@if (!view) {
|
||||
<ng-container>
|
||||
<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>
|
||||
} @else {
|
||||
<ng-container>
|
||||
<app-display-payment-method
|
||||
[owner]="view.account"
|
||||
[paymentMethod]="view.paymentMethod"
|
||||
(updated)="setPaymentMethod($event)"
|
||||
></app-display-payment-method>
|
||||
|
||||
<app-display-account-credit
|
||||
[owner]="view.account"
|
||||
[credit]="view.credit"
|
||||
></app-display-account-credit>
|
||||
</ng-container>
|
||||
}
|
||||
</bit-container>
|
||||
@@ -0,0 +1,116 @@
|
||||
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 { 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";
|
||||
import {
|
||||
DisplayAccountCreditComponent,
|
||||
DisplayPaymentMethodComponent,
|
||||
} from "../../payment/components";
|
||||
import { MaskedPaymentMethod } from "../../payment/types";
|
||||
import { BillingClient } from "../../services";
|
||||
import { accountToBillableEntity, BillableEntity } from "../../types";
|
||||
|
||||
class RedirectError {
|
||||
constructor(
|
||||
public path: string[],
|
||||
public relativeTo: ActivatedRoute,
|
||||
) {}
|
||||
}
|
||||
|
||||
type View = {
|
||||
account: BillableEntity;
|
||||
paymentMethod: MaskedPaymentMethod | null;
|
||||
credit: number | null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
templateUrl: "./account-payment-details.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
DisplayAccountCreditComponent,
|
||||
DisplayPaymentMethodComponent,
|
||||
HeaderModule,
|
||||
SharedModule,
|
||||
],
|
||||
providers: [BillingClient],
|
||||
})
|
||||
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;
|
||||
}),
|
||||
),
|
||||
),
|
||||
accountToBillableEntity,
|
||||
switchMap(async (account) => {
|
||||
const [paymentMethod, credit] = await Promise.all([
|
||||
this.billingClient.getPaymentMethod(account),
|
||||
this.billingClient.getCredit(account),
|
||||
]);
|
||||
|
||||
return {
|
||||
account,
|
||||
paymentMethod,
|
||||
credit,
|
||||
};
|
||||
}),
|
||||
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(
|
||||
this.load$.pipe(tap((view) => this.viewState$.next(view))),
|
||||
this.viewState$.pipe(filter((view): view is View => view !== null)),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private billingClient: BillingClient,
|
||||
private configService: ConfigService,
|
||||
private router: Router,
|
||||
) {}
|
||||
|
||||
setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => {
|
||||
if (this.viewState$.value) {
|
||||
this.viewState$.next({
|
||||
...this.viewState$.value,
|
||||
paymentMethod,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
<bit-tab-link [route]="(hasPremium$ | async) ? 'user-subscription' : 'premium'">{{
|
||||
"subscription" | i18n
|
||||
}}</bit-tab-link>
|
||||
<bit-tab-link route="payment-method">{{ "paymentMethod" | i18n }}</bit-tab-link>
|
||||
@let paymentMethodPageData = paymentDetailsPageData$ | async;
|
||||
<bit-tab-link [route]="paymentMethodPageData.route">{{
|
||||
paymentMethodPageData.textKey | i18n
|
||||
}}</bit-tab-link>
|
||||
<bit-tab-link route="billing-history">{{ "billingHistory" | i18n }}</bit-tab-link>
|
||||
</bit-tab-nav-bar>
|
||||
</app-header>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { Observable, switchMap } from "rxjs";
|
||||
import { map, 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({
|
||||
@@ -13,16 +15,32 @@ 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() {
|
||||
|
||||
@@ -8,10 +8,10 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
|
||||
import { OrganizationSponsorshipInvitesResponse } from "@bitwarden/common/billing/models/response/organization-sponsorship-invites.response";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { canAccessBillingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/organizations/payment-details/organization-payment-details.component";
|
||||
|
||||
import { organizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard";
|
||||
import { organizationIsUnmanaged } from "../../billing/guards/organization-is-unmanaged.guard";
|
||||
@@ -36,6 +37,17 @@ const routes: Routes = [
|
||||
titleId: "paymentMethod",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "payment-details",
|
||||
component: OrganizationPaymentDetailsComponent,
|
||||
canActivate: [
|
||||
organizationPermissionsGuard((org) => org.canEditPaymentMethods),
|
||||
organizationIsUnmanaged,
|
||||
],
|
||||
data: {
|
||||
titleId: "paymentDetails",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "history",
|
||||
component: OrgBillingHistoryViewComponent,
|
||||
|
||||
@@ -48,11 +48,11 @@ import { BillingResponse } from "@bitwarden/common/billing/models/response/billi
|
||||
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";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
@let organization = organization$ | async;
|
||||
@if (organization) {
|
||||
<app-organization-free-trial-warning
|
||||
[organization]="organization"
|
||||
(clicked)="changePaymentMethod()"
|
||||
>
|
||||
</app-organization-free-trial-warning>
|
||||
}
|
||||
<app-header></app-header>
|
||||
<bit-container>
|
||||
@let view = view$ | async;
|
||||
@if (!view) {
|
||||
<ng-container>
|
||||
<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>
|
||||
} @else {
|
||||
<ng-container>
|
||||
<app-display-payment-method
|
||||
[owner]="view.organization"
|
||||
[paymentMethod]="view.paymentMethod"
|
||||
(updated)="setPaymentMethod($event)"
|
||||
></app-display-payment-method>
|
||||
|
||||
<app-display-billing-address
|
||||
[owner]="view.organization"
|
||||
[billingAddress]="view.billingAddress"
|
||||
(updated)="setBillingAddress($event)"
|
||||
></app-display-billing-address>
|
||||
|
||||
<app-display-account-credit
|
||||
[owner]="view.organization"
|
||||
[credit]="view.credit"
|
||||
></app-display-account-credit>
|
||||
</ng-container>
|
||||
}
|
||||
</bit-container>
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Component, OnInit, ViewChild } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
EMPTY,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
lastValueFrom,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
getOrganizationById,
|
||||
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 { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { HeaderModule } from "../../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../../shared";
|
||||
import {
|
||||
ChangePaymentMethodDialogComponent,
|
||||
DisplayAccountCreditComponent,
|
||||
DisplayBillingAddressComponent,
|
||||
DisplayPaymentMethodComponent,
|
||||
} from "../../payment/components";
|
||||
import { BillingAddress, MaskedPaymentMethod } from "../../payment/types";
|
||||
import { BillingClient } from "../../services";
|
||||
import { BillableEntity, organizationToBillableEntity } from "../../types";
|
||||
import { OrganizationFreeTrialWarningComponent } from "../../warnings/components";
|
||||
|
||||
class RedirectError {
|
||||
constructor(
|
||||
public path: string[],
|
||||
public relativeTo: ActivatedRoute,
|
||||
) {}
|
||||
}
|
||||
|
||||
type View = {
|
||||
organization: BillableEntity;
|
||||
paymentMethod: MaskedPaymentMethod | null;
|
||||
billingAddress: BillingAddress | null;
|
||||
credit: number | null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
templateUrl: "./organization-payment-details.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
DisplayBillingAddressComponent,
|
||||
DisplayAccountCreditComponent,
|
||||
DisplayPaymentMethodComponent,
|
||||
HeaderModule,
|
||||
OrganizationFreeTrialWarningComponent,
|
||||
SharedModule,
|
||||
],
|
||||
providers: [BillingClient],
|
||||
})
|
||||
export class OrganizationPaymentDetailsComponent implements OnInit {
|
||||
@ViewChild(OrganizationFreeTrialWarningComponent)
|
||||
organizationFreeTrialWarningComponent!: OrganizationFreeTrialWarningComponent;
|
||||
|
||||
private viewState$ = new BehaviorSubject<View | null>(null);
|
||||
|
||||
private load$: Observable<View> = this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
switchMap((organization) =>
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
|
||||
.pipe(
|
||||
map((managePaymentDetailsOutsideCheckout) => {
|
||||
if (!managePaymentDetailsOutsideCheckout) {
|
||||
throw new RedirectError(["../payment-method"], this.activatedRoute);
|
||||
}
|
||||
return organization;
|
||||
}),
|
||||
),
|
||||
),
|
||||
organizationToBillableEntity,
|
||||
switchMap(async (organization) => {
|
||||
const [paymentMethod, billingAddress, credit] = await Promise.all([
|
||||
this.billingClient.getPaymentMethod(organization),
|
||||
this.billingClient.getBillingAddress(organization),
|
||||
this.billingClient.getCredit(organization),
|
||||
]);
|
||||
|
||||
return {
|
||||
organization,
|
||||
paymentMethod,
|
||||
billingAddress,
|
||||
credit,
|
||||
};
|
||||
}),
|
||||
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(
|
||||
this.load$.pipe(tap((view) => this.viewState$.next(view))),
|
||||
this.viewState$.pipe(filter((view): view is View => view !== null)),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
|
||||
organization$ = this.view$.pipe(map((view) => view.organization.data as Organization));
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private billingClient: BillingClient,
|
||||
private configService: ConfigService,
|
||||
private dialogService: DialogService,
|
||||
private organizationService: OrganizationService,
|
||||
private router: Router,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
const openChangePaymentMethodDialogOnStart =
|
||||
(history.state?.launchPaymentModalAutomatically as boolean) ?? false;
|
||||
|
||||
if (openChangePaymentMethodDialogOnStart) {
|
||||
history.replaceState({ ...history.state, launchPaymentModalAutomatically: false }, "");
|
||||
await this.changePaymentMethod();
|
||||
}
|
||||
}
|
||||
|
||||
changePaymentMethod = async () => {
|
||||
const view = await firstValueFrom(this.view$);
|
||||
const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
owner: view.organization,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result?.type === "success") {
|
||||
await this.setPaymentMethod(result.paymentMethod);
|
||||
this.organizationFreeTrialWarningComponent.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
setBillingAddress = (billingAddress: BillingAddress) => {
|
||||
if (this.viewState$.value) {
|
||||
this.viewState$.next({
|
||||
...this.viewState$.value,
|
||||
billingAddress,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setPaymentMethod = async (paymentMethod: MaskedPaymentMethod) => {
|
||||
if (this.viewState$.value) {
|
||||
const billingAddress =
|
||||
this.viewState$.value.billingAddress ??
|
||||
(await this.billingClient.getBillingAddress(this.viewState$.value.organization));
|
||||
|
||||
this.viewState$.next({
|
||||
...this.viewState$.value,
|
||||
paymentMethod,
|
||||
billingAddress,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ 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 { firstValueFrom, from, lastValueFrom, map, switchMap } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, from, lastValueFrom, map, switchMap } from "rxjs";
|
||||
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import {
|
||||
@@ -19,6 +19,8 @@ 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";
|
||||
@@ -72,18 +74,28 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
private accountService: AccountService,
|
||||
protected syncService: SyncService,
|
||||
private billingNotificationService: BillingNotificationService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.activatedRoute.params
|
||||
combineLatest([
|
||||
this.activatedRoute.params,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout),
|
||||
])
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
switchMap(({ organizationId }) => {
|
||||
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();
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { Component, ElementRef, Inject, ViewChild } from "@angular/core";
|
||||
import {
|
||||
AbstractControl,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ValidationErrors,
|
||||
ValidatorFn,
|
||||
Validators,
|
||||
} from "@angular/forms";
|
||||
import { map } from "rxjs";
|
||||
|
||||
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 { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BillingClient } from "../../services";
|
||||
import { BillableEntity } from "../../types";
|
||||
|
||||
type DialogParams = {
|
||||
owner: BillableEntity;
|
||||
};
|
||||
|
||||
type DialogResult = "cancelled" | "error" | "launched";
|
||||
|
||||
type PayPalConfig = {
|
||||
businessId: string;
|
||||
buttonAction: string;
|
||||
};
|
||||
|
||||
declare const process: {
|
||||
env: {
|
||||
PAYPAL_CONFIG: PayPalConfig;
|
||||
};
|
||||
};
|
||||
|
||||
const positiveNumberValidator =
|
||||
(message: string): ValidatorFn =>
|
||||
(control: AbstractControl): ValidationErrors | null => {
|
||||
if (!control.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = parseFloat(control.value);
|
||||
|
||||
if (isNaN(value) || value <= 0) {
|
||||
return { notPositiveNumber: { message } };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog>
|
||||
<span bitDialogTitle class="tw-font-semibold">
|
||||
{{ "addCredit" | i18n }}
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<p bitTypography="body1">{{ "creditDelayed" | i18n }}</p>
|
||||
<div class="tw-grid tw-grid-cols-2">
|
||||
<bit-radio-group [formControl]="formGroup.controls.paymentMethod">
|
||||
<bit-radio-button id="credit-method-paypal" [value]="'payPal'">
|
||||
<bit-label> <i class="bwi bwi-paypal"></i>PayPal</bit-label>
|
||||
</bit-radio-button>
|
||||
<bit-radio-button id="credit-method-bitcoin" [value]="'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
|
||||
[formControl]="formGroup.controls.amount"
|
||||
type="text"
|
||||
(blur)="formatAmount()"
|
||||
required
|
||||
/>
|
||||
<span bitPrefix>$USD</span>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary">
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
[bitDialogClose]="'cancelled'"
|
||||
>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
<form #payPalForm action="{{ payPalConfig.buttonAction }}" method="post" target="_top">
|
||||
<input type="hidden" name="cmd" value="_xclick" />
|
||||
<input type="hidden" name="business" value="{{ payPalConfig.businessId }}" />
|
||||
<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="{{ redirectUrl }}" />
|
||||
<input type="hidden" name="cancel_return" value="{{ redirectUrl }}" />
|
||||
<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="{{ amount }}" />
|
||||
<input type="hidden" name="custom" value="{{ payPalCustom$ | async }}" />
|
||||
<input type="hidden" name="item_name" value="Bitwarden Account Credit" />
|
||||
<input type="hidden" name="item_number" value="{{ payPalSubject }}" />
|
||||
</form>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [SharedModule],
|
||||
providers: [BillingClient],
|
||||
})
|
||||
export class AddAccountCreditDialogComponent {
|
||||
@ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm!: ElementRef;
|
||||
|
||||
protected payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig;
|
||||
protected redirectUrl = window.location.href;
|
||||
|
||||
protected formGroup = new FormGroup({
|
||||
paymentMethod: new FormControl<"payPal" | "bitPay">("payPal"),
|
||||
amount: new FormControl<string | null>("0.00", [
|
||||
Validators.required,
|
||||
positiveNumberValidator(this.i18nService.t("mustBePositiveNumber")),
|
||||
]),
|
||||
});
|
||||
|
||||
protected payPalCustom$ = this.configService.cloudRegion$.pipe(
|
||||
map((cloudRegion) => {
|
||||
switch (this.dialogParams.owner.type) {
|
||||
case "account": {
|
||||
return `user_id=${this.dialogParams.owner.data.id},account_credit=1,region=${cloudRegion}`;
|
||||
}
|
||||
case "organization": {
|
||||
return `organization_id=${this.dialogParams.owner.data.id},account_credit=1,region=${cloudRegion}`;
|
||||
}
|
||||
case "provider": {
|
||||
return `provider_id=${this.dialogParams.owner.data.id},account_credit=1,region=${cloudRegion}`;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private billingClient: BillingClient,
|
||||
private configService: ConfigService,
|
||||
@Inject(DIALOG_DATA) private dialogParams: DialogParams,
|
||||
private dialogRef: DialogRef<DialogResult>,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
submit = async (): Promise<void> => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (!this.formGroup.valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.formGroup.value.paymentMethod === "bitPay") {
|
||||
const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.owner, {
|
||||
amount: this.amount!,
|
||||
redirectUrl: this.redirectUrl,
|
||||
});
|
||||
|
||||
switch (result.type) {
|
||||
case "success": {
|
||||
this.platformUtilsService.launchUri(result.value);
|
||||
this.dialogRef.close("launched");
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: result.message,
|
||||
});
|
||||
this.dialogRef.close("error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.payPalForm.nativeElement.submit();
|
||||
this.dialogRef.close("launched");
|
||||
};
|
||||
|
||||
formatAmount = (): void => {
|
||||
if (this.formGroup.value.amount) {
|
||||
const amount = parseFloat(this.formGroup.value.amount);
|
||||
if (isNaN(amount)) {
|
||||
this.formGroup.controls.amount.setValue(null);
|
||||
} else {
|
||||
this.formGroup.controls.amount.setValue(amount.toFixed(2).toString());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
get amount(): number | null {
|
||||
if (this.formGroup.value.amount) {
|
||||
const amount = parseFloat(this.formGroup.value.amount);
|
||||
if (isNaN(amount)) {
|
||||
return null;
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get payPalSubject(): string {
|
||||
switch (this.dialogParams.owner.type) {
|
||||
case "account": {
|
||||
return this.dialogParams.owner.data.email;
|
||||
}
|
||||
case "organization":
|
||||
case "provider": {
|
||||
return this.dialogParams.owner.data.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) =>
|
||||
dialogService.open<DialogResult>(AddAccountCreditDialogComponent, dialogConfig);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { Component, Inject, ViewChild } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BillingClient } from "../../services";
|
||||
import { BillableEntity } from "../../types";
|
||||
import { MaskedPaymentMethod } from "../types";
|
||||
|
||||
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
|
||||
|
||||
type DialogParams = {
|
||||
owner: BillableEntity;
|
||||
};
|
||||
|
||||
type DialogResult =
|
||||
| { type: "cancelled" }
|
||||
| { type: "error" }
|
||||
| { type: "success"; paymentMethod: MaskedPaymentMethod };
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog>
|
||||
<span bitDialogTitle class="tw-font-semibold">
|
||||
{{ "changePaymentMethod" | i18n }}
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<app-enter-payment-method [group]="formGroup" [includeBillingAddress]="true">
|
||||
</app-enter-payment-method>
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton bitFormButton buttonType="primary" type="submit">
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
[bitDialogClose]="{ type: 'cancelled' }"
|
||||
>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [EnterPaymentMethodComponent, SharedModule],
|
||||
providers: [BillingClient],
|
||||
})
|
||||
export class ChangePaymentMethodDialogComponent {
|
||||
@ViewChild(EnterPaymentMethodComponent)
|
||||
private enterPaymentMethodComponent!: EnterPaymentMethodComponent;
|
||||
protected formGroup = EnterPaymentMethodComponent.getFormGroup();
|
||||
|
||||
constructor(
|
||||
private billingClient: BillingClient,
|
||||
@Inject(DIALOG_DATA) protected dialogParams: DialogParams,
|
||||
private dialogRef: DialogRef<DialogResult>,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (!this.formGroup.valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentMethod = await this.enterPaymentMethodComponent.tokenize();
|
||||
const billingAddress =
|
||||
this.formGroup.value.type !== "payPal"
|
||||
? this.formGroup.controls.billingAddress.getRawValue()
|
||||
: null;
|
||||
|
||||
const result = await this.billingClient.updatePaymentMethod(
|
||||
this.dialogParams.owner,
|
||||
paymentMethod,
|
||||
billingAddress,
|
||||
);
|
||||
|
||||
switch (result.type) {
|
||||
case "success": {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("paymentMethodUpdated"),
|
||||
});
|
||||
this.dialogRef.close({
|
||||
type: "success",
|
||||
paymentMethod: result.value,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: result.message,
|
||||
});
|
||||
this.dialogRef.close({ type: "error" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) =>
|
||||
dialogService.open<DialogResult>(ChangePaymentMethodDialogComponent, dialogConfig);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { CurrencyPipe } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BillingClient } from "../../services";
|
||||
import { BillableEntity } from "../../types";
|
||||
|
||||
import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-display-account-credit",
|
||||
template: `
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "accountCredit" | i18n }}: {{ formattedCredit }}</h2>
|
||||
<p>{{ "availableCreditAppliedToInvoice" | i18n }}</p>
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="addAccountCredit">
|
||||
{{ "addCredit" | i18n }}
|
||||
</button>
|
||||
</bit-section>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [SharedModule],
|
||||
providers: [BillingClient, CurrencyPipe],
|
||||
})
|
||||
export class DisplayAccountCreditComponent {
|
||||
@Input({ required: true }) owner!: BillableEntity;
|
||||
@Input({ required: true }) credit!: number | null;
|
||||
|
||||
constructor(
|
||||
private billingClient: BillingClient,
|
||||
private currencyPipe: CurrencyPipe,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
addAccountCredit = async () => {
|
||||
if (this.owner.type !== "account") {
|
||||
const billingAddress = await this.billingClient.getBillingAddress(this.owner);
|
||||
if (!billingAddress) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("billingAddressRequiredToAddCredit"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
AddAccountCreditDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
owner: this.owner,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
get formattedCredit(): string | null {
|
||||
const credit = this.credit ?? 0;
|
||||
return this.currencyPipe.transform(credit, "$");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BillableEntity } from "../../types";
|
||||
import { AddressPipe } from "../pipes";
|
||||
import { BillingAddress } from "../types";
|
||||
|
||||
import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-display-billing-address",
|
||||
template: `
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "billingAddress" | i18n }}</h2>
|
||||
@if (billingAddress) {
|
||||
<p>{{ billingAddress | address }}</p>
|
||||
@if (billingAddress.taxId) {
|
||||
<p>{{ "taxId" | i18n: billingAddress.taxId.value }}</p>
|
||||
}
|
||||
} @else {
|
||||
<p>{{ "noBillingAddress" | i18n }}</p>
|
||||
}
|
||||
@let key = billingAddress ? "editBillingAddress" : "addBillingAddress";
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="editBillingAddress">
|
||||
{{ key | i18n }}
|
||||
</button>
|
||||
</bit-section>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [AddressPipe, SharedModule],
|
||||
})
|
||||
export class DisplayBillingAddressComponent {
|
||||
@Input({ required: true }) owner!: BillableEntity;
|
||||
@Input({ required: true }) billingAddress!: BillingAddress | null;
|
||||
@Output() updated = new EventEmitter<BillingAddress>();
|
||||
|
||||
constructor(private dialogService: DialogService) {}
|
||||
|
||||
editBillingAddress = async (): Promise<void> => {
|
||||
const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
owner: this.owner,
|
||||
billingAddress: this.billingAddress,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
if (result?.type === "success") {
|
||||
this.updated.emit(result.billingAddress);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BillableEntity } from "../../types";
|
||||
import { MaskedPaymentMethod } from "../types";
|
||||
|
||||
import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component";
|
||||
import { VerifyBankAccountComponent } from "./verify-bank-account.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-display-payment-method",
|
||||
template: `
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
|
||||
@if (paymentMethod) {
|
||||
@switch (paymentMethod.type) {
|
||||
@case ("bankAccount") {
|
||||
@if (!paymentMethod.verified) {
|
||||
<app-verify-bank-account [owner]="owner" (verified)="onBankAccountVerified($event)">
|
||||
</app-verify-bank-account>
|
||||
}
|
||||
|
||||
<p>
|
||||
<i class="bwi bwi-fw bwi-billing"></i>
|
||||
{{ paymentMethod.bankName }}, *{{ paymentMethod.last4 }}
|
||||
@if (!paymentMethod.verified) {
|
||||
<span>- {{ "unverified" | i18n }}</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
@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>
|
||||
} @else {
|
||||
<i class="bwi bwi-fw bwi-credit-card"></i>
|
||||
}
|
||||
{{ paymentMethod.brand | titlecase }}, *{{ paymentMethod.last4 }},
|
||||
{{ paymentMethod.expiration }}
|
||||
</p>
|
||||
}
|
||||
@case ("payPal") {
|
||||
<p>
|
||||
<i class="bwi bwi-fw bwi-paypal tw-text-primary-600"></i>
|
||||
{{ paymentMethod.email }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<p bitTypography="body1">{{ "noPaymentMethod" | i18n }}</p>
|
||||
}
|
||||
@let key = paymentMethod ? "changePaymentMethod" : "addPaymentMethod";
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="changePaymentMethod">
|
||||
{{ key | i18n }}
|
||||
</button>
|
||||
</bit-section>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [SharedModule, VerifyBankAccountComponent],
|
||||
})
|
||||
export class DisplayPaymentMethodComponent {
|
||||
@Input({ required: true }) owner!: BillableEntity;
|
||||
@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> => {
|
||||
const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
owner: this.owner,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
if (result?.type === "success") {
|
||||
this.updated.emit(result.paymentMethod);
|
||||
}
|
||||
};
|
||||
|
||||
onBankAccountVerified = (paymentMethod: MaskedPaymentMethod) => this.updated.emit(paymentMethod);
|
||||
|
||||
protected getBrandIconForCard = (): string | null => {
|
||||
if (this.paymentMethod?.type !== "card") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.paymentMethod.brand in this.availableCardIcons
|
||||
? this.availableCardIcons[this.paymentMethod.brand]
|
||||
: null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BillingClient } from "../../services";
|
||||
import { BillableEntity } from "../../types";
|
||||
import { BillingAddress, getTaxIdTypeForCountry } from "../types";
|
||||
|
||||
import { EnterBillingAddressComponent } from "./enter-billing-address.component";
|
||||
|
||||
type DialogParams = {
|
||||
owner: BillableEntity;
|
||||
billingAddress: BillingAddress | null;
|
||||
};
|
||||
|
||||
type DialogResult =
|
||||
| { type: "cancelled" }
|
||||
| { type: "error" }
|
||||
| { type: "success"; billingAddress: BillingAddress };
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog>
|
||||
<span bitDialogTitle class="tw-font-semibold">
|
||||
{{ "editBillingAddress" | i18n }}
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<app-enter-billing-address
|
||||
[scenario]="{
|
||||
type: 'update',
|
||||
existing: dialogParams.billingAddress,
|
||||
supportsTaxId,
|
||||
}"
|
||||
[group]="formGroup"
|
||||
></app-enter-billing-address>
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton bitFormButton buttonType="primary" type="submit">
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
[bitDialogClose]="{ type: 'cancelled' }"
|
||||
>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [EnterBillingAddressComponent, SharedModule],
|
||||
providers: [BillingClient],
|
||||
})
|
||||
export class EditBillingAddressDialogComponent {
|
||||
protected formGroup = EnterBillingAddressComponent.getFormGroup();
|
||||
|
||||
constructor(
|
||||
private billingClient: BillingClient,
|
||||
@Inject(DIALOG_DATA) protected dialogParams: DialogParams,
|
||||
private dialogRef: DialogRef<DialogResult>,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
) {
|
||||
if (dialogParams.billingAddress) {
|
||||
this.formGroup.patchValue({
|
||||
...dialogParams.billingAddress,
|
||||
taxId: dialogParams.billingAddress.taxId?.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
submit = async (): Promise<void> => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
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 result = await this.billingClient.updateBillingAddress(
|
||||
this.dialogParams.owner,
|
||||
billingAddress,
|
||||
);
|
||||
|
||||
switch (result.type) {
|
||||
case "success": {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("billingAddressUpdated"),
|
||||
});
|
||||
this.dialogRef.close({
|
||||
type: "success",
|
||||
billingAddress: result.value,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: result.message,
|
||||
});
|
||||
this.dialogRef.close({
|
||||
type: "error",
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
get supportsTaxId(): boolean {
|
||||
switch (this.dialogParams.owner.type) {
|
||||
case "account": {
|
||||
return false;
|
||||
}
|
||||
case "organization": {
|
||||
return [
|
||||
ProductTierType.TeamsStarter,
|
||||
ProductTierType.Teams,
|
||||
ProductTierType.Enterprise,
|
||||
].includes(this.dialogParams.owner.data.productTierType);
|
||||
}
|
||||
case "provider": {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) =>
|
||||
dialogService.open<DialogResult>(EditBillingAddressDialogComponent, dialogConfig);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Component, Input, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { map, Observable, startWith, Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { ControlsOf } from "@bitwarden/angular/types/controls-of";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BillingAddress, selectableCountries, taxIdTypes } from "../types";
|
||||
|
||||
export interface BillingAddressControls {
|
||||
country: string;
|
||||
postalCode: string;
|
||||
line1: string | null;
|
||||
line2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
taxId: string | null;
|
||||
}
|
||||
|
||||
export type BillingAddressFormGroup = FormGroup<ControlsOf<BillingAddressControls>>;
|
||||
|
||||
type Scenario =
|
||||
| {
|
||||
type: "checkout";
|
||||
supportsTaxId: boolean;
|
||||
}
|
||||
| {
|
||||
type: "update";
|
||||
existing?: BillingAddress;
|
||||
supportsTaxId: boolean;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "app-enter-billing-address",
|
||||
template: `
|
||||
<form [formGroup]="group">
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "country" | i18n }}</bit-label>
|
||||
<bit-select [formControl]="group.controls.country" data-testid="country">
|
||||
@for (selectableCountry of selectableCountries; track selectableCountry.value) {
|
||||
<bit-option
|
||||
[value]="selectableCountry.value"
|
||||
[disabled]="selectableCountry.disabled"
|
||||
[label]="selectableCountry.name"
|
||||
></bit-option>
|
||||
}
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.postalCode"
|
||||
autocomplete="postal-code"
|
||||
data-testid="postal-code"
|
||||
/>
|
||||
</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 (supportsTaxId$ | async) {
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.taxId"
|
||||
data-testid="tax-id"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [SharedModule],
|
||||
})
|
||||
export class EnterBillingAddressComponent implements OnInit, OnDestroy {
|
||||
@Input({ required: true }) scenario!: Scenario;
|
||||
@Input({ required: true }) group!: BillingAddressFormGroup;
|
||||
|
||||
protected selectableCountries = selectableCountries;
|
||||
protected supportsTaxId$!: Observable<boolean>;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
ngOnInit() {
|
||||
switch (this.scenario.type) {
|
||||
case "checkout": {
|
||||
this.disableAddressControls();
|
||||
break;
|
||||
}
|
||||
case "update": {
|
||||
if (this.scenario.existing) {
|
||||
this.group.patchValue({
|
||||
...this.scenario.existing,
|
||||
taxId: this.scenario.existing.taxId?.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.supportsTaxId$ = this.group.controls.country.valueChanges.pipe(
|
||||
startWith(this.group.value.country ?? this.selectableCountries[0].value),
|
||||
map((country) => {
|
||||
if (!this.scenario.supportsTaxId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return taxIdTypes.filter((taxIdType) => taxIdType.iso === country).length > 0;
|
||||
}),
|
||||
);
|
||||
|
||||
this.supportsTaxId$.pipe(takeUntil(this.destroy$)).subscribe((supportsTaxId) => {
|
||||
if (supportsTaxId) {
|
||||
this.group.controls.taxId.enable();
|
||||
} else {
|
||||
this.group.controls.taxId.disable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
disableAddressControls = () => {
|
||||
this.group.controls.line1.disable();
|
||||
this.group.controls.line2.disable();
|
||||
this.group.controls.city.disable();
|
||||
this.group.controls.state.disable();
|
||||
};
|
||||
|
||||
static getFormGroup = (): BillingAddressFormGroup =>
|
||||
new FormGroup({
|
||||
country: new FormControl<string>("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
postalCode: new FormControl<string>("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
line1: new FormControl<string | null>(null),
|
||||
line2: new FormControl<string | null>(null),
|
||||
city: new FormControl<string | null>(null),
|
||||
state: new FormControl<string | null>(null),
|
||||
taxId: new FormControl<string | null>(null),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { BehaviorSubject, startWith, Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
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,
|
||||
TokenizablePaymentMethod,
|
||||
TokenizedPaymentMethod,
|
||||
} from "../types";
|
||||
|
||||
type PaymentMethodOption = TokenizablePaymentMethod | "accountCredit";
|
||||
|
||||
type PaymentMethodFormGroup = FormGroup<{
|
||||
type: FormControl<PaymentMethodOption>;
|
||||
bankAccount: FormGroup<{
|
||||
routingNumber: FormControl<string>;
|
||||
accountNumber: FormControl<string>;
|
||||
accountHolderName: FormControl<string>;
|
||||
accountHolderType: FormControl<"" | "company" | "individual">;
|
||||
}>;
|
||||
billingAddress: FormGroup<{
|
||||
country: FormControl<string>;
|
||||
postalCode: FormControl<string>;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
@Component({
|
||||
selector: "app-enter-payment-method",
|
||||
template: `
|
||||
@let showBillingDetails = includeBillingAddress && selected !== "payPal";
|
||||
<form [formGroup]="group">
|
||||
@if (showBillingDetails) {
|
||||
<h5 bitTypography="h5">{{ "paymentMethod" | i18n }}</h5>
|
||||
}
|
||||
<div class="tw-mb-4 tw-text-lg">
|
||||
<bit-radio-group [formControl]="group.controls.type">
|
||||
<bit-radio-button id="card-payment-method" [value]="'card'">
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-credit-card" aria-hidden="true"></i>
|
||||
{{ "creditCard" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
@if (showBankAccount) {
|
||||
<bit-radio-button id="bank-payment-method" [value]="'bankAccount'">
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-billing" aria-hidden="true"></i>
|
||||
{{ "bankAccount" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
}
|
||||
@if (showPayPal) {
|
||||
<bit-radio-button id="paypal-payment-method" [value]="'payPal'">
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-paypal" aria-hidden="true"></i>
|
||||
{{ "payPal" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
}
|
||||
@if (showAccountCredit) {
|
||||
<bit-radio-button id="credit-payment-method" [value]="'accountCredit'">
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-dollar" aria-hidden="true"></i>
|
||||
{{ "accountCredit" | i18n }}
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
}
|
||||
</bit-radio-group>
|
||||
</div>
|
||||
@switch (selected) {
|
||||
@case ("card") {
|
||||
<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 }}
|
||||
<button
|
||||
[bitPopoverTriggerFor]="cardSecurityCodePopover"
|
||||
type="button"
|
||||
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-p-0"
|
||||
[position]="'above-end'"
|
||||
>
|
||||
<i class="bwi bwi-question-circle tw-text-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
<bit-popover [title]="'cardSecurityCode' | i18n" #cardSecurityCodePopover>
|
||||
<p>{{ "cardSecurityCodeDescription" | i18n }}</p>
|
||||
</bit-popover>
|
||||
</app-payment-label>
|
||||
<div id="stripe-card-cvc" class="tw-stripe-form-control"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ("bankAccount") {
|
||||
<ng-container>
|
||||
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
|
||||
{{ "verifyBankAccountWarning" | i18n }}
|
||||
</bit-callout>
|
||||
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4" formGroupName="bankAccount">
|
||||
<bit-form-field class="tw-col-span-1" [disableMargin]="true">
|
||||
<bit-label>{{ "routingNumber" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
id="routingNumber"
|
||||
type="text"
|
||||
[formControl]="group.controls.bankAccount.controls.routingNumber"
|
||||
required
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-1" [disableMargin]="true">
|
||||
<bit-label>{{ "accountNumber" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
id="accountNumber"
|
||||
type="text"
|
||||
[formControl]="group.controls.bankAccount.controls.accountNumber"
|
||||
required
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-1" [disableMargin]="true">
|
||||
<bit-label>{{ "accountHolderName" | i18n }}</bit-label>
|
||||
<input
|
||||
id="accountHolderName"
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.bankAccount.controls.accountHolderName"
|
||||
required
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-1" [disableMargin]="true">
|
||||
<bit-label>{{ "bankAccountType" | i18n }}</bit-label>
|
||||
<bit-select
|
||||
id="accountHolderType"
|
||||
[formControl]="group.controls.bankAccount.controls.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>
|
||||
}
|
||||
@case ("payPal") {
|
||||
<ng-container>
|
||||
<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>
|
||||
}
|
||||
@case ("accountCredit") {
|
||||
<ng-container>
|
||||
<bit-callout type="info">
|
||||
{{ "makeSureEnoughCredit" | i18n }}
|
||||
</bit-callout>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
@if (showBillingDetails) {
|
||||
<h5 bitTypography="h5">{{ "billingAddress" | i18n }}</h5>
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "country" | i18n }}</bit-label>
|
||||
<bit-select [formControl]="group.controls.billingAddress.controls.country">
|
||||
@for (selectableCountry of selectableCountries; track selectableCountry.value) {
|
||||
<bit-option
|
||||
[value]="selectableCountry.value"
|
||||
[disabled]="selectableCountry.disabled"
|
||||
[label]="selectableCountry.name"
|
||||
></bit-option>
|
||||
}
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.billingAddress.controls.postalCode"
|
||||
autocomplete="postal-code"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [BillingServicesModule, PaymentLabelComponent, PopoverModule, SharedModule],
|
||||
})
|
||||
export class EnterPaymentMethodComponent implements OnInit {
|
||||
@Input({ required: true }) group!: PaymentMethodFormGroup;
|
||||
|
||||
private showBankAccountSubject = new BehaviorSubject<boolean>(true);
|
||||
showBankAccount$ = this.showBankAccountSubject.asObservable();
|
||||
@Input()
|
||||
set showBankAccount(value: boolean) {
|
||||
this.showBankAccountSubject.next(value);
|
||||
}
|
||||
get showBankAccount(): boolean {
|
||||
return this.showBankAccountSubject.value;
|
||||
}
|
||||
|
||||
@Input() showPayPal: boolean = true;
|
||||
@Input() showAccountCredit: boolean = false;
|
||||
@Input() includeBillingAddress: boolean = false;
|
||||
|
||||
protected selectableCountries = selectableCountries;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private braintreeService: BraintreeService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private stripeService: StripeService,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.stripeService.loadStripe(
|
||||
{
|
||||
cardNumber: "#stripe-card-number",
|
||||
cardExpiry: "#stripe-card-expiry",
|
||||
cardCvc: "#stripe-card-cvc",
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
if (this.showPayPal) {
|
||||
this.braintreeService.loadBraintree("#braintree-container", false);
|
||||
}
|
||||
|
||||
if (!this.includeBillingAddress) {
|
||||
this.group.controls.billingAddress.disable();
|
||||
}
|
||||
|
||||
this.group.controls.type.valueChanges
|
||||
.pipe(startWith(this.group.controls.type.value), takeUntil(this.destroy$))
|
||||
.subscribe((selected) => {
|
||||
if (selected === "bankAccount") {
|
||||
this.group.controls.bankAccount.enable();
|
||||
if (this.includeBillingAddress) {
|
||||
this.group.controls.billingAddress.enable();
|
||||
}
|
||||
} else {
|
||||
switch (selected) {
|
||||
case "card": {
|
||||
this.stripeService.mountElements();
|
||||
if (this.includeBillingAddress) {
|
||||
this.group.controls.billingAddress.enable();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "payPal": {
|
||||
this.braintreeService.createDropin();
|
||||
if (this.includeBillingAddress) {
|
||||
this.group.controls.billingAddress.disable();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.group.controls.bankAccount.disable();
|
||||
}
|
||||
});
|
||||
|
||||
this.showBankAccount$.pipe(takeUntil(this.destroy$)).subscribe((showBankAccount) => {
|
||||
if (!showBankAccount && this.selected === "bankAccount") {
|
||||
this.select("card");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
select = (paymentMethod: PaymentMethodOption) =>
|
||||
this.group.controls.type.patchValue(paymentMethod);
|
||||
|
||||
tokenize = async (): Promise<TokenizedPaymentMethod> => {
|
||||
const exchange = async (paymentMethod: TokenizablePaymentMethod) => {
|
||||
switch (paymentMethod) {
|
||||
case "bankAccount": {
|
||||
this.group.controls.bankAccount.markAllAsTouched();
|
||||
if (!this.group.controls.bankAccount.valid) {
|
||||
throw new Error("Attempted to tokenize invalid bank account information.");
|
||||
}
|
||||
|
||||
const bankAccount = this.group.controls.bankAccount.getRawValue();
|
||||
const clientSecret = await this.stripeService.createSetupIntent("bankAccount");
|
||||
const billingDetails = this.group.controls.billingAddress.enabled
|
||||
? this.group.controls.billingAddress.getRawValue()
|
||||
: undefined;
|
||||
return await this.stripeService.setupBankAccountPaymentMethod(
|
||||
clientSecret,
|
||||
bankAccount,
|
||||
billingDetails,
|
||||
);
|
||||
}
|
||||
case "card": {
|
||||
const clientSecret = await this.stripeService.createSetupIntent("card");
|
||||
const billingDetails = this.group.controls.billingAddress.enabled
|
||||
? this.group.controls.billingAddress.getRawValue()
|
||||
: undefined;
|
||||
return this.stripeService.setupCardPaymentMethod(clientSecret, billingDetails);
|
||||
}
|
||||
case "payPal": {
|
||||
return this.braintreeService.requestPaymentMethod();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isTokenizablePaymentMethod(this.selected)) {
|
||||
throw new Error(`Attempted to tokenize a non-tokenizable payment method: ${this.selected}`);
|
||||
}
|
||||
|
||||
try {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
validate = (): boolean => {
|
||||
if (this.selected === "bankAccount") {
|
||||
this.group.controls.bankAccount.markAllAsTouched();
|
||||
return this.group.controls.bankAccount.valid;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
get selected(): PaymentMethodOption {
|
||||
return this.group.value.type!;
|
||||
}
|
||||
|
||||
static getFormGroup = (): PaymentMethodFormGroup =>
|
||||
new FormGroup({
|
||||
type: new FormControl<PaymentMethodOption>("card", { nonNullable: true }),
|
||||
bankAccount: new FormGroup({
|
||||
routingNumber: new FormControl<string>("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
accountNumber: new FormControl<string>("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
accountHolderName: new FormControl<string>("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
accountHolderType: new FormControl<"" | "company" | "individual">("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
}),
|
||||
billingAddress: new FormGroup({
|
||||
country: new FormControl<string>("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
postalCode: new FormControl<string>("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
9
apps/web/src/app/billing/payment/components/index.ts
Normal file
9
apps/web/src/app/billing/payment/components/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./add-account-credit-dialog.component";
|
||||
export * from "./change-payment-method-dialog.component";
|
||||
export * from "./display-account-credit.component";
|
||||
export * from "./display-billing-address.component";
|
||||
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 "./verify-bank-account.component";
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BillingClient } from "../../services";
|
||||
import { BillableEntity } from "../../types";
|
||||
import { MaskedPaymentMethod } from "../types";
|
||||
|
||||
@Component({
|
||||
selector: "app-verify-bank-account",
|
||||
template: `
|
||||
<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"
|
||||
[formControl]="formGroup.controls.descriptorCode"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary">
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
</form>
|
||||
</bit-callout>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [SharedModule],
|
||||
providers: [BillingClient],
|
||||
})
|
||||
export class VerifyBankAccountComponent {
|
||||
@Input({ required: true }) owner!: BillableEntity;
|
||||
@Output() verified = new EventEmitter<MaskedPaymentMethod>();
|
||||
|
||||
protected formGroup = new FormGroup({
|
||||
descriptorCode: new FormControl<string>("", [
|
||||
Validators.required,
|
||||
Validators.minLength(6),
|
||||
Validators.maxLength(6),
|
||||
]),
|
||||
});
|
||||
|
||||
constructor(
|
||||
private billingClient: BillingClient,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
submit = async (): Promise<void> => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (!this.formGroup.valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.billingClient.verifyBankAccount(
|
||||
this.owner,
|
||||
this.formGroup.value.descriptorCode!,
|
||||
);
|
||||
|
||||
switch (result.type) {
|
||||
case "success": {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("bankAccountVerified"),
|
||||
});
|
||||
this.verified.emit(result.value);
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: result.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
65
apps/web/src/app/billing/payment/pipes/address.pipe.spec.ts
Normal file
65
apps/web/src/app/billing/payment/pipes/address.pipe.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { AddressPipe } from "./address.pipe";
|
||||
|
||||
describe("AddressPipe", () => {
|
||||
let pipe: AddressPipe;
|
||||
|
||||
beforeEach(() => {
|
||||
pipe = new AddressPipe();
|
||||
});
|
||||
|
||||
it("should format a complete address with all fields", () => {
|
||||
const address = {
|
||||
country: "United States",
|
||||
postalCode: "10001",
|
||||
line1: "123 Main St",
|
||||
line2: "Apt 4B",
|
||||
city: "New York",
|
||||
state: "NY",
|
||||
};
|
||||
|
||||
const result = pipe.transform(address);
|
||||
expect(result).toBe("123 Main St, Apt 4B, New York, NY, 10001, United States");
|
||||
});
|
||||
|
||||
it("should format address without line2", () => {
|
||||
const address = {
|
||||
country: "United States",
|
||||
postalCode: "10001",
|
||||
line1: "123 Main St",
|
||||
line2: null,
|
||||
city: "New York",
|
||||
state: "NY",
|
||||
};
|
||||
|
||||
const result = pipe.transform(address);
|
||||
expect(result).toBe("123 Main St, New York, NY, 10001, United States");
|
||||
});
|
||||
|
||||
it("should format address without state", () => {
|
||||
const address = {
|
||||
country: "United Kingdom",
|
||||
postalCode: "SW1A 1AA",
|
||||
line1: "123 Main St",
|
||||
line2: "Apt 4B",
|
||||
city: "London",
|
||||
state: null,
|
||||
};
|
||||
|
||||
const result = pipe.transform(address);
|
||||
expect(result).toBe("123 Main St, Apt 4B, London, SW1A 1AA, United Kingdom");
|
||||
});
|
||||
|
||||
it("should format minimal address with only required fields", () => {
|
||||
const address = {
|
||||
country: "United States",
|
||||
postalCode: "10001",
|
||||
line1: null,
|
||||
line2: null,
|
||||
city: null,
|
||||
state: null,
|
||||
};
|
||||
|
||||
const result = pipe.transform(address);
|
||||
expect(result).toBe("10001, United States");
|
||||
});
|
||||
});
|
||||
32
apps/web/src/app/billing/payment/pipes/address.pipe.ts
Normal file
32
apps/web/src/app/billing/payment/pipes/address.pipe.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Pipe, PipeTransform } from "@angular/core";
|
||||
|
||||
import { BillingAddress } from "../types";
|
||||
|
||||
@Pipe({
|
||||
name: "address",
|
||||
})
|
||||
export class AddressPipe implements PipeTransform {
|
||||
transform(address: Omit<BillingAddress, "taxId">): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (address.line1) {
|
||||
parts.push(address.line1);
|
||||
}
|
||||
|
||||
if (address.line2) {
|
||||
parts.push(address.line2);
|
||||
}
|
||||
|
||||
if (address.city) {
|
||||
parts.push(address.city);
|
||||
}
|
||||
|
||||
if (address.state) {
|
||||
parts.push(address.state);
|
||||
}
|
||||
|
||||
parts.push(address.postalCode, address.country);
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
}
|
||||
1
apps/web/src/app/billing/payment/pipes/index.ts
Normal file
1
apps/web/src/app/billing/payment/pipes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./address.pipe";
|
||||
37
apps/web/src/app/billing/payment/types/billing-address.ts
Normal file
37
apps/web/src/app/billing/payment/types/billing-address.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
import { TaxId, TaxIdResponse } from "./tax-id";
|
||||
|
||||
export type BillingAddress = {
|
||||
country: string;
|
||||
postalCode: string;
|
||||
line1: string | null;
|
||||
line2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
taxId: TaxId | null;
|
||||
};
|
||||
|
||||
export class BillingAddressResponse extends BaseResponse implements BillingAddress {
|
||||
country: string;
|
||||
postalCode: string;
|
||||
line1: string | null;
|
||||
line2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
taxId: TaxId | null;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.country = this.getResponseProperty("Country");
|
||||
this.postalCode = this.getResponseProperty("PostalCode");
|
||||
this.line1 = this.getResponseProperty("Line1");
|
||||
this.line2 = this.getResponseProperty("Line2");
|
||||
this.city = this.getResponseProperty("City");
|
||||
this.state = this.getResponseProperty("State");
|
||||
|
||||
const taxId = this.getResponseProperty("TaxId");
|
||||
this.taxId = taxId ? new TaxIdResponse(taxId) : null;
|
||||
}
|
||||
}
|
||||
6
apps/web/src/app/billing/payment/types/index.ts
Normal file
6
apps/web/src/app/billing/payment/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./billing-address";
|
||||
export * from "./masked-payment-method";
|
||||
export * from "./selectable-country";
|
||||
export * from "./tax-id";
|
||||
export * from "./tax-id-type";
|
||||
export * from "./tokenized-payment-method";
|
||||
114
apps/web/src/app/billing/payment/types/masked-payment-method.ts
Normal file
114
apps/web/src/app/billing/payment/types/masked-payment-method.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
import {
|
||||
BankAccountPaymentMethod,
|
||||
CardPaymentMethod,
|
||||
PayPalPaymentMethod,
|
||||
} from "./tokenized-payment-method";
|
||||
|
||||
export const StripeCardBrands = {
|
||||
amex: "amex",
|
||||
diners: "diners",
|
||||
discover: "discover",
|
||||
eftpos_au: "eftpos_au",
|
||||
jcb: "jcb",
|
||||
link: "link",
|
||||
mastercard: "mastercard",
|
||||
unionpay: "unionpay",
|
||||
visa: "visa",
|
||||
unknown: "unknown",
|
||||
} as const;
|
||||
|
||||
export type StripeCardBrand = (typeof StripeCardBrands)[keyof typeof StripeCardBrands];
|
||||
|
||||
type MaskedBankAccount = {
|
||||
type: BankAccountPaymentMethod;
|
||||
bankName: string;
|
||||
last4: string;
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
type MaskedCard = {
|
||||
type: CardPaymentMethod;
|
||||
brand: StripeCardBrand;
|
||||
last4: string;
|
||||
expiration: string;
|
||||
};
|
||||
|
||||
type MaskedPayPalAccount = {
|
||||
type: PayPalPaymentMethod;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type MaskedPaymentMethod = MaskedBankAccount | MaskedCard | MaskedPayPalAccount;
|
||||
|
||||
export class MaskedPaymentMethodResponse extends BaseResponse {
|
||||
value: MaskedPaymentMethod;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const type = this.getResponseProperty("Type");
|
||||
switch (type) {
|
||||
case "card": {
|
||||
this.value = new MaskedCardResponse(response);
|
||||
break;
|
||||
}
|
||||
case "bankAccount": {
|
||||
this.value = new MaskedBankAccountResponse(response);
|
||||
break;
|
||||
}
|
||||
case "payPal": {
|
||||
this.value = new MaskedPayPalAccountResponse(response);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Cannot deserialize unsupported payment method type: ${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MaskedBankAccountResponse extends BaseResponse implements MaskedBankAccount {
|
||||
type: BankAccountPaymentMethod;
|
||||
bankName: string;
|
||||
last4: string;
|
||||
verified: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.type = "bankAccount";
|
||||
this.bankName = this.getResponseProperty("BankName");
|
||||
this.last4 = this.getResponseProperty("Last4");
|
||||
this.verified = this.getResponseProperty("Verified");
|
||||
}
|
||||
}
|
||||
|
||||
class MaskedCardResponse extends BaseResponse implements MaskedCard {
|
||||
type: CardPaymentMethod;
|
||||
brand: StripeCardBrand;
|
||||
last4: string;
|
||||
expiration: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.type = "card";
|
||||
this.brand = this.getResponseProperty("Brand");
|
||||
this.last4 = this.getResponseProperty("Last4");
|
||||
this.expiration = this.getResponseProperty("Expiration");
|
||||
}
|
||||
}
|
||||
|
||||
class MaskedPayPalAccountResponse extends BaseResponse implements MaskedPayPalAccount {
|
||||
type: PayPalPaymentMethod;
|
||||
email: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.type = "payPal";
|
||||
this.email = this.getResponseProperty("Email");
|
||||
}
|
||||
}
|
||||
259
apps/web/src/app/billing/payment/types/selectable-country.ts
Normal file
259
apps/web/src/app/billing/payment/types/selectable-country.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
type SelectableCountry = Readonly<{
|
||||
name: string;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
}>;
|
||||
|
||||
export const selectableCountries: ReadonlyArray<SelectableCountry> = [
|
||||
{ name: "-- Select --", value: "", disabled: false },
|
||||
{ name: "United States", value: "US", disabled: false },
|
||||
{ name: "China", value: "CN", disabled: false },
|
||||
{ name: "France", value: "FR", disabled: false },
|
||||
{ name: "Germany", value: "DE", disabled: false },
|
||||
{ name: "Canada", value: "CA", disabled: false },
|
||||
{ name: "United Kingdom", value: "GB", disabled: false },
|
||||
{ name: "Australia", value: "AU", disabled: false },
|
||||
{ name: "India", value: "IN", disabled: false },
|
||||
{ name: "", value: "-", disabled: true },
|
||||
{ name: "Afghanistan", value: "AF", disabled: false },
|
||||
{ name: "Åland Islands", value: "AX", disabled: false },
|
||||
{ name: "Albania", value: "AL", disabled: false },
|
||||
{ name: "Algeria", value: "DZ", disabled: false },
|
||||
{ name: "American Samoa", value: "AS", disabled: false },
|
||||
{ name: "Andorra", value: "AD", disabled: false },
|
||||
{ name: "Angola", value: "AO", disabled: false },
|
||||
{ name: "Anguilla", value: "AI", disabled: false },
|
||||
{ name: "Antarctica", value: "AQ", disabled: false },
|
||||
{ name: "Antigua and Barbuda", value: "AG", disabled: false },
|
||||
{ name: "Argentina", value: "AR", disabled: false },
|
||||
{ name: "Armenia", value: "AM", disabled: false },
|
||||
{ name: "Aruba", value: "AW", disabled: false },
|
||||
{ name: "Austria", value: "AT", disabled: false },
|
||||
{ name: "Azerbaijan", value: "AZ", disabled: false },
|
||||
{ name: "Bahamas", value: "BS", disabled: false },
|
||||
{ name: "Bahrain", value: "BH", disabled: false },
|
||||
{ name: "Bangladesh", value: "BD", disabled: false },
|
||||
{ name: "Barbados", value: "BB", disabled: false },
|
||||
{ name: "Belarus", value: "BY", disabled: false },
|
||||
{ name: "Belgium", value: "BE", disabled: false },
|
||||
{ name: "Belize", value: "BZ", disabled: false },
|
||||
{ name: "Benin", value: "BJ", disabled: false },
|
||||
{ name: "Bermuda", value: "BM", disabled: false },
|
||||
{ name: "Bhutan", value: "BT", disabled: false },
|
||||
{ name: "Bolivia, Plurinational State of", value: "BO", disabled: false },
|
||||
{ name: "Bonaire, Sint Eustatius and Saba", value: "BQ", disabled: false },
|
||||
{ name: "Bosnia and Herzegovina", value: "BA", disabled: false },
|
||||
{ name: "Botswana", value: "BW", disabled: false },
|
||||
{ name: "Bouvet Island", value: "BV", disabled: false },
|
||||
{ name: "Brazil", value: "BR", disabled: false },
|
||||
{ name: "British Indian Ocean Territory", value: "IO", disabled: false },
|
||||
{ name: "Brunei Darussalam", value: "BN", disabled: false },
|
||||
{ name: "Bulgaria", value: "BG", disabled: false },
|
||||
{ name: "Burkina Faso", value: "BF", disabled: false },
|
||||
{ name: "Burundi", value: "BI", disabled: false },
|
||||
{ name: "Cambodia", value: "KH", disabled: false },
|
||||
{ name: "Cameroon", value: "CM", disabled: false },
|
||||
{ name: "Cape Verde", value: "CV", disabled: false },
|
||||
{ name: "Cayman Islands", value: "KY", disabled: false },
|
||||
{ name: "Central African Republic", value: "CF", disabled: false },
|
||||
{ name: "Chad", value: "TD", disabled: false },
|
||||
{ name: "Chile", value: "CL", disabled: false },
|
||||
{ name: "Christmas Island", value: "CX", disabled: false },
|
||||
{ name: "Cocos (Keeling) Islands", value: "CC", disabled: false },
|
||||
{ name: "Colombia", value: "CO", disabled: false },
|
||||
{ name: "Comoros", value: "KM", disabled: false },
|
||||
{ name: "Congo", value: "CG", disabled: false },
|
||||
{ name: "Congo, the Democratic Republic of the", value: "CD", disabled: false },
|
||||
{ name: "Cook Islands", value: "CK", disabled: false },
|
||||
{ name: "Costa Rica", value: "CR", disabled: false },
|
||||
{ name: "Côte d'Ivoire", value: "CI", disabled: false },
|
||||
{ name: "Croatia", value: "HR", disabled: false },
|
||||
{ name: "Cuba", value: "CU", disabled: false },
|
||||
{ name: "Curaçao", value: "CW", disabled: false },
|
||||
{ name: "Cyprus", value: "CY", disabled: false },
|
||||
{ name: "Czech Republic", value: "CZ", disabled: false },
|
||||
{ name: "Denmark", value: "DK", disabled: false },
|
||||
{ name: "Djibouti", value: "DJ", disabled: false },
|
||||
{ name: "Dominica", value: "DM", disabled: false },
|
||||
{ name: "Dominican Republic", value: "DO", disabled: false },
|
||||
{ name: "Ecuador", value: "EC", disabled: false },
|
||||
{ name: "Egypt", value: "EG", disabled: false },
|
||||
{ name: "El Salvador", value: "SV", disabled: false },
|
||||
{ name: "Equatorial Guinea", value: "GQ", disabled: false },
|
||||
{ name: "Eritrea", value: "ER", disabled: false },
|
||||
{ name: "Estonia", value: "EE", disabled: false },
|
||||
{ name: "Ethiopia", value: "ET", disabled: false },
|
||||
{ name: "Falkland Islands (Malvinas)", value: "FK", disabled: false },
|
||||
{ name: "Faroe Islands", value: "FO", disabled: false },
|
||||
{ name: "Fiji", value: "FJ", disabled: false },
|
||||
{ name: "Finland", value: "FI", disabled: false },
|
||||
{ name: "French Guiana", value: "GF", disabled: false },
|
||||
{ name: "French Polynesia", value: "PF", disabled: false },
|
||||
{ name: "French Southern Territories", value: "TF", disabled: false },
|
||||
{ name: "Gabon", value: "GA", disabled: false },
|
||||
{ name: "Gambia", value: "GM", disabled: false },
|
||||
{ name: "Georgia", value: "GE", disabled: false },
|
||||
{ name: "Ghana", value: "GH", disabled: false },
|
||||
{ name: "Gibraltar", value: "GI", disabled: false },
|
||||
{ name: "Greece", value: "GR", disabled: false },
|
||||
{ name: "Greenland", value: "GL", disabled: false },
|
||||
{ name: "Grenada", value: "GD", disabled: false },
|
||||
{ name: "Guadeloupe", value: "GP", disabled: false },
|
||||
{ name: "Guam", value: "GU", disabled: false },
|
||||
{ name: "Guatemala", value: "GT", disabled: false },
|
||||
{ name: "Guernsey", value: "GG", disabled: false },
|
||||
{ name: "Guinea", value: "GN", disabled: false },
|
||||
{ name: "Guinea-Bissau", value: "GW", disabled: false },
|
||||
{ name: "Guyana", value: "GY", disabled: false },
|
||||
{ name: "Haiti", value: "HT", disabled: false },
|
||||
{ name: "Heard Island and McDonald Islands", value: "HM", disabled: false },
|
||||
{ name: "Holy See (Vatican City State)", value: "VA", disabled: false },
|
||||
{ name: "Honduras", value: "HN", disabled: false },
|
||||
{ name: "Hong Kong", value: "HK", disabled: false },
|
||||
{ name: "Hungary", value: "HU", disabled: false },
|
||||
{ name: "Iceland", value: "IS", disabled: false },
|
||||
{ name: "Indonesia", value: "ID", disabled: false },
|
||||
{ name: "Iran, Islamic Republic of", value: "IR", disabled: false },
|
||||
{ name: "Iraq", value: "IQ", disabled: false },
|
||||
{ name: "Ireland", value: "IE", disabled: false },
|
||||
{ name: "Isle of Man", value: "IM", disabled: false },
|
||||
{ name: "Israel", value: "IL", disabled: false },
|
||||
{ name: "Italy", value: "IT", disabled: false },
|
||||
{ name: "Jamaica", value: "JM", disabled: false },
|
||||
{ name: "Japan", value: "JP", disabled: false },
|
||||
{ name: "Jersey", value: "JE", disabled: false },
|
||||
{ name: "Jordan", value: "JO", disabled: false },
|
||||
{ name: "Kazakhstan", value: "KZ", disabled: false },
|
||||
{ name: "Kenya", value: "KE", disabled: false },
|
||||
{ name: "Kiribati", value: "KI", disabled: false },
|
||||
{ name: "Korea, Democratic People's Republic of", value: "KP", disabled: false },
|
||||
{ name: "Korea, Republic of", value: "KR", disabled: false },
|
||||
{ name: "Kuwait", value: "KW", disabled: false },
|
||||
{ name: "Kyrgyzstan", value: "KG", disabled: false },
|
||||
{ name: "Lao People's Democratic Republic", value: "LA", disabled: false },
|
||||
{ name: "Latvia", value: "LV", disabled: false },
|
||||
{ name: "Lebanon", value: "LB", disabled: false },
|
||||
{ name: "Lesotho", value: "LS", disabled: false },
|
||||
{ name: "Liberia", value: "LR", disabled: false },
|
||||
{ name: "Libya", value: "LY", disabled: false },
|
||||
{ name: "Liechtenstein", value: "LI", disabled: false },
|
||||
{ name: "Lithuania", value: "LT", disabled: false },
|
||||
{ name: "Luxembourg", value: "LU", disabled: false },
|
||||
{ name: "Macao", value: "MO", disabled: false },
|
||||
{ name: "Macedonia, the former Yugoslav Republic of", value: "MK", disabled: false },
|
||||
{ name: "Madagascar", value: "MG", disabled: false },
|
||||
{ name: "Malawi", value: "MW", disabled: false },
|
||||
{ name: "Malaysia", value: "MY", disabled: false },
|
||||
{ name: "Maldives", value: "MV", disabled: false },
|
||||
{ name: "Mali", value: "ML", disabled: false },
|
||||
{ name: "Malta", value: "MT", disabled: false },
|
||||
{ name: "Marshall Islands", value: "MH", disabled: false },
|
||||
{ name: "Martinique", value: "MQ", disabled: false },
|
||||
{ name: "Mauritania", value: "MR", disabled: false },
|
||||
{ name: "Mauritius", value: "MU", disabled: false },
|
||||
{ name: "Mayotte", value: "YT", disabled: false },
|
||||
{ name: "Mexico", value: "MX", disabled: false },
|
||||
{ name: "Micronesia, Federated States of", value: "FM", disabled: false },
|
||||
{ name: "Moldova, Republic of", value: "MD", disabled: false },
|
||||
{ name: "Monaco", value: "MC", disabled: false },
|
||||
{ name: "Mongolia", value: "MN", disabled: false },
|
||||
{ name: "Montenegro", value: "ME", disabled: false },
|
||||
{ name: "Montserrat", value: "MS", disabled: false },
|
||||
{ name: "Morocco", value: "MA", disabled: false },
|
||||
{ name: "Mozambique", value: "MZ", disabled: false },
|
||||
{ name: "Myanmar", value: "MM", disabled: false },
|
||||
{ name: "Namibia", value: "NA", disabled: false },
|
||||
{ name: "Nauru", value: "NR", disabled: false },
|
||||
{ name: "Nepal", value: "NP", disabled: false },
|
||||
{ name: "Netherlands", value: "NL", disabled: false },
|
||||
{ name: "New Caledonia", value: "NC", disabled: false },
|
||||
{ name: "New Zealand", value: "NZ", disabled: false },
|
||||
{ name: "Nicaragua", value: "NI", disabled: false },
|
||||
{ name: "Niger", value: "NE", disabled: false },
|
||||
{ name: "Nigeria", value: "NG", disabled: false },
|
||||
{ name: "Niue", value: "NU", disabled: false },
|
||||
{ name: "Norfolk Island", value: "NF", disabled: false },
|
||||
{ name: "Northern Mariana Islands", value: "MP", disabled: false },
|
||||
{ name: "Norway", value: "NO", disabled: false },
|
||||
{ name: "Oman", value: "OM", disabled: false },
|
||||
{ name: "Pakistan", value: "PK", disabled: false },
|
||||
{ name: "Palau", value: "PW", disabled: false },
|
||||
{ name: "Palestinian Territory, Occupied", value: "PS", disabled: false },
|
||||
{ name: "Panama", value: "PA", disabled: false },
|
||||
{ name: "Papua New Guinea", value: "PG", disabled: false },
|
||||
{ name: "Paraguay", value: "PY", disabled: false },
|
||||
{ name: "Peru", value: "PE", disabled: false },
|
||||
{ name: "Philippines", value: "PH", disabled: false },
|
||||
{ name: "Pitcairn", value: "PN", disabled: false },
|
||||
{ name: "Poland", value: "PL", disabled: false },
|
||||
{ name: "Portugal", value: "PT", disabled: false },
|
||||
{ name: "Puerto Rico", value: "PR", disabled: false },
|
||||
{ name: "Qatar", value: "QA", disabled: false },
|
||||
{ name: "Réunion", value: "RE", disabled: false },
|
||||
{ name: "Romania", value: "RO", disabled: false },
|
||||
{ name: "Russian Federation", value: "RU", disabled: false },
|
||||
{ name: "Rwanda", value: "RW", disabled: false },
|
||||
{ name: "Saint Barthélemy", value: "BL", disabled: false },
|
||||
{ name: "Saint Helena, Ascension and Tristan da Cunha", value: "SH", disabled: false },
|
||||
{ name: "Saint Kitts and Nevis", value: "KN", disabled: false },
|
||||
{ name: "Saint Lucia", value: "LC", disabled: false },
|
||||
{ name: "Saint Martin (French part)", value: "MF", disabled: false },
|
||||
{ name: "Saint Pierre and Miquelon", value: "PM", disabled: false },
|
||||
{ name: "Saint Vincent and the Grenadines", value: "VC", disabled: false },
|
||||
{ name: "Samoa", value: "WS", disabled: false },
|
||||
{ name: "San Marino", value: "SM", disabled: false },
|
||||
{ name: "Sao Tome and Principe", value: "ST", disabled: false },
|
||||
{ name: "Saudi Arabia", value: "SA", disabled: false },
|
||||
{ name: "Senegal", value: "SN", disabled: false },
|
||||
{ name: "Serbia", value: "RS", disabled: false },
|
||||
{ name: "Seychelles", value: "SC", disabled: false },
|
||||
{ name: "Sierra Leone", value: "SL", disabled: false },
|
||||
{ name: "Singapore", value: "SG", disabled: false },
|
||||
{ name: "Sint Maarten (Dutch part)", value: "SX", disabled: false },
|
||||
{ name: "Slovakia", value: "SK", disabled: false },
|
||||
{ name: "Slovenia", value: "SI", disabled: false },
|
||||
{ name: "Solomon Islands", value: "SB", disabled: false },
|
||||
{ name: "Somalia", value: "SO", disabled: false },
|
||||
{ name: "South Africa", value: "ZA", disabled: false },
|
||||
{ name: "South Georgia and the South Sandwich Islands", value: "GS", disabled: false },
|
||||
{ name: "South Sudan", value: "SS", disabled: false },
|
||||
{ name: "Spain", value: "ES", disabled: false },
|
||||
{ name: "Sri Lanka", value: "LK", disabled: false },
|
||||
{ name: "Sudan", value: "SD", disabled: false },
|
||||
{ name: "Suriname", value: "SR", disabled: false },
|
||||
{ name: "Svalbard and Jan Mayen", value: "SJ", disabled: false },
|
||||
{ name: "Swaziland", value: "SZ", disabled: false },
|
||||
{ name: "Sweden", value: "SE", disabled: false },
|
||||
{ name: "Switzerland", value: "CH", disabled: false },
|
||||
{ name: "Syrian Arab Republic", value: "SY", disabled: false },
|
||||
{ name: "Taiwan", value: "TW", disabled: false },
|
||||
{ name: "Tajikistan", value: "TJ", disabled: false },
|
||||
{ name: "Tanzania, United Republic of", value: "TZ", disabled: false },
|
||||
{ name: "Thailand", value: "TH", disabled: false },
|
||||
{ name: "Timor-Leste", value: "TL", disabled: false },
|
||||
{ name: "Togo", value: "TG", disabled: false },
|
||||
{ name: "Tokelau", value: "TK", disabled: false },
|
||||
{ name: "Tonga", value: "TO", disabled: false },
|
||||
{ name: "Trinidad and Tobago", value: "TT", disabled: false },
|
||||
{ name: "Tunisia", value: "TN", disabled: false },
|
||||
{ name: "Turkey", value: "TR", disabled: false },
|
||||
{ name: "Turkmenistan", value: "TM", disabled: false },
|
||||
{ name: "Turks and Caicos Islands", value: "TC", disabled: false },
|
||||
{ name: "Tuvalu", value: "TV", disabled: false },
|
||||
{ name: "Uganda", value: "UG", disabled: false },
|
||||
{ name: "Ukraine", value: "UA", disabled: false },
|
||||
{ name: "United Arab Emirates", value: "AE", disabled: false },
|
||||
{ name: "United States Minor Outlying Islands", value: "UM", disabled: false },
|
||||
{ name: "Uruguay", value: "UY", disabled: false },
|
||||
{ name: "Uzbekistan", value: "UZ", disabled: false },
|
||||
{ name: "Vanuatu", value: "VU", disabled: false },
|
||||
{ name: "Venezuela, Bolivarian Republic of", value: "VE", disabled: false },
|
||||
{ name: "Viet Nam", value: "VN", disabled: false },
|
||||
{ name: "Virgin Islands, British", value: "VG", disabled: false },
|
||||
{ name: "Virgin Islands, U.S.", value: "VI", disabled: false },
|
||||
{ name: "Wallis and Futuna", value: "WF", disabled: false },
|
||||
{ name: "Western Sahara", value: "EH", disabled: false },
|
||||
{ name: "Yemen", value: "YE", disabled: false },
|
||||
{ name: "Zambia", value: "ZM", disabled: false },
|
||||
{ name: "Zimbabwe", value: "ZW", disabled: false },
|
||||
];
|
||||
1123
apps/web/src/app/billing/payment/types/tax-id-type.ts
Normal file
1123
apps/web/src/app/billing/payment/types/tax-id-type.ts
Normal file
File diff suppressed because it is too large
Load Diff
18
apps/web/src/app/billing/payment/types/tax-id.ts
Normal file
18
apps/web/src/app/billing/payment/types/tax-id.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export interface TaxId {
|
||||
code: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class TaxIdResponse extends BaseResponse implements TaxId {
|
||||
code: string;
|
||||
value: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.code = this.getResponseProperty("Code");
|
||||
this.value = this.getResponseProperty("Value");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const TokenizablePaymentMethods = {
|
||||
bankAccount: "bankAccount",
|
||||
card: "card",
|
||||
payPal: "payPal",
|
||||
} as const;
|
||||
|
||||
export type BankAccountPaymentMethod = typeof TokenizablePaymentMethods.bankAccount;
|
||||
export type CardPaymentMethod = typeof TokenizablePaymentMethods.card;
|
||||
export type PayPalPaymentMethod = typeof TokenizablePaymentMethods.payPal;
|
||||
|
||||
export type TokenizablePaymentMethod =
|
||||
(typeof TokenizablePaymentMethods)[keyof typeof TokenizablePaymentMethods];
|
||||
|
||||
export const isTokenizablePaymentMethod = (value: string): value is TokenizablePaymentMethod => {
|
||||
const valid = Object.values(TokenizablePaymentMethods) as readonly string[];
|
||||
return valid.includes(value);
|
||||
};
|
||||
|
||||
export type TokenizedPaymentMethod = {
|
||||
type: TokenizablePaymentMethod;
|
||||
token: string;
|
||||
};
|
||||
153
apps/web/src/app/billing/services/billing.client.ts
Normal file
153
apps/web/src/app/billing/services/billing.client.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
|
||||
import {
|
||||
BillingAddress,
|
||||
BillingAddressResponse,
|
||||
MaskedPaymentMethod,
|
||||
MaskedPaymentMethodResponse,
|
||||
TokenizedPaymentMethod,
|
||||
} from "../payment/types";
|
||||
import { BillableEntity } from "../types";
|
||||
|
||||
type Result<T> =
|
||||
| {
|
||||
type: "success";
|
||||
value: T;
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BillingClient {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
private getEndpoint = (entity: BillableEntity): string => {
|
||||
switch (entity.type) {
|
||||
case "account": {
|
||||
return "/account/billing/vnext";
|
||||
}
|
||||
case "organization": {
|
||||
return `/organizations/${entity.data.id}/billing/vnext`;
|
||||
}
|
||||
case "provider": {
|
||||
return `/providers/${entity.data.id}/billing/vnext`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
addCreditWithBitPay = async (
|
||||
owner: BillableEntity,
|
||||
credit: { amount: number; redirectUrl: string },
|
||||
): Promise<Result<string>> => {
|
||||
const path = `${this.getEndpoint(owner)}/credit/bitpay`;
|
||||
try {
|
||||
const data = await this.apiService.send("POST", path, credit, true, true);
|
||||
return {
|
||||
type: "success",
|
||||
value: data as string,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error instanceof ErrorResponse) {
|
||||
return {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
getBillingAddress = async (owner: BillableEntity): Promise<BillingAddress | null> => {
|
||||
const path = `${this.getEndpoint(owner)}/address`;
|
||||
const data = await this.apiService.send("GET", path, null, true, true);
|
||||
return data ? new BillingAddressResponse(data) : null;
|
||||
};
|
||||
|
||||
getCredit = async (owner: BillableEntity): Promise<number | null> => {
|
||||
const path = `${this.getEndpoint(owner)}/credit`;
|
||||
const data = await this.apiService.send("GET", path, null, true, true);
|
||||
return data ? (data as number) : null;
|
||||
};
|
||||
|
||||
getPaymentMethod = async (owner: BillableEntity): Promise<MaskedPaymentMethod | null> => {
|
||||
const path = `${this.getEndpoint(owner)}/payment-method`;
|
||||
const data = await this.apiService.send("GET", path, null, true, true);
|
||||
return data ? new MaskedPaymentMethodResponse(data).value : null;
|
||||
};
|
||||
|
||||
updateBillingAddress = async (
|
||||
owner: BillableEntity,
|
||||
billingAddress: BillingAddress,
|
||||
): Promise<Result<BillingAddress>> => {
|
||||
const path = `${this.getEndpoint(owner)}/address`;
|
||||
try {
|
||||
const data = await this.apiService.send("PUT", path, billingAddress, true, true);
|
||||
return {
|
||||
type: "success",
|
||||
value: new BillingAddressResponse(data),
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error instanceof ErrorResponse) {
|
||||
return {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updatePaymentMethod = async (
|
||||
owner: BillableEntity,
|
||||
paymentMethod: TokenizedPaymentMethod,
|
||||
billingAddress: Pick<BillingAddress, "country" | "postalCode"> | null,
|
||||
): Promise<Result<MaskedPaymentMethod>> => {
|
||||
const path = `${this.getEndpoint(owner)}/payment-method`;
|
||||
try {
|
||||
const request = {
|
||||
...paymentMethod,
|
||||
billingAddress,
|
||||
};
|
||||
const data = await this.apiService.send("PUT", path, request, true, true);
|
||||
return {
|
||||
type: "success",
|
||||
value: new MaskedPaymentMethodResponse(data).value,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error instanceof ErrorResponse) {
|
||||
return {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
verifyBankAccount = async (
|
||||
owner: BillableEntity,
|
||||
descriptorCode: string,
|
||||
): Promise<Result<MaskedPaymentMethod>> => {
|
||||
const path = `${this.getEndpoint(owner)}/payment-method/verify-bank-account`;
|
||||
try {
|
||||
const data = await this.apiService.send("POST", path, { descriptorCode }, true, true);
|
||||
return {
|
||||
type: "success",
|
||||
value: new MaskedPaymentMethodResponse(data).value,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error instanceof ErrorResponse) {
|
||||
return {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./billing.client";
|
||||
export * from "./billing-services.module";
|
||||
export * from "./braintree.service";
|
||||
export * from "./stripe.service";
|
||||
|
||||
@@ -2,11 +2,43 @@
|
||||
// @ts-strict-ignore
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { BankAccount } from "@bitwarden/common/billing/models/domain";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
import { BankAccountPaymentMethod, CardPaymentMethod } from "../payment/types";
|
||||
|
||||
import { BillingServicesModule } from "./billing-services.module";
|
||||
|
||||
type SetupBankAccountRequest = {
|
||||
payment_method: {
|
||||
us_bank_account: {
|
||||
routing_number: string;
|
||||
account_number: string;
|
||||
account_holder_type: string;
|
||||
};
|
||||
billing_details: {
|
||||
name: string;
|
||||
address?: {
|
||||
country: string;
|
||||
postal_code: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type SetupCardRequest = {
|
||||
payment_method: {
|
||||
card: string;
|
||||
billing_details?: {
|
||||
address: {
|
||||
country: string;
|
||||
postal_code: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: BillingServicesModule })
|
||||
export class StripeService {
|
||||
private stripe: any;
|
||||
@@ -17,7 +49,28 @@ export class StripeService {
|
||||
cardCvc: string;
|
||||
};
|
||||
|
||||
constructor(private logService: LogService) {}
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
createSetupIntent = async (
|
||||
paymentMethod: BankAccountPaymentMethod | CardPaymentMethod,
|
||||
): Promise<string> => {
|
||||
const getPath = () => {
|
||||
switch (paymentMethod) {
|
||||
case "bankAccount": {
|
||||
return "/setup-intent/bank-account";
|
||||
}
|
||||
case "card": {
|
||||
return "/setup-intent/card";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const response = await this.apiService.send("POST", getPath(), null, true, true);
|
||||
return response as string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads [Stripe JS]{@link https://docs.stripe.com/js} in the <head> element of the current page and mounts
|
||||
@@ -51,25 +104,28 @@ export class StripeService {
|
||||
window.document.head.appendChild(script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-mounts previously created Stripe credit card [elements]{@link https://docs.stripe.com/js/elements_object/create} into the HTML elements
|
||||
* specified during the {@link loadStripe} call. This is useful for when those HTML elements are removed from the DOM by Angular.
|
||||
*/
|
||||
mountElements(i: number = 0) {
|
||||
mountElements(attempt: number = 1) {
|
||||
setTimeout(() => {
|
||||
if (!document.querySelector(this.elementIds.cardNumber) && i < 10) {
|
||||
this.logService.warning("Stripe container missing, retrying...");
|
||||
this.mountElements(i + 1);
|
||||
return;
|
||||
}
|
||||
if (!this.elements) {
|
||||
this.logService.warning(`Stripe elements are missing, retrying for attempt ${attempt}...`);
|
||||
this.mountElements(attempt + 1);
|
||||
} else {
|
||||
const cardNumber = this.elements.getElement("cardNumber");
|
||||
const cardExpiry = this.elements.getElement("cardExpiry");
|
||||
const cardCVC = this.elements.getElement("cardCvc");
|
||||
|
||||
const cardNumber = this.elements.getElement("cardNumber");
|
||||
const cardExpiry = this.elements.getElement("cardExpiry");
|
||||
const cardCvc = this.elements.getElement("cardCvc");
|
||||
cardNumber.mount(this.elementIds.cardNumber);
|
||||
cardExpiry.mount(this.elementIds.cardExpiry);
|
||||
cardCvc.mount(this.elementIds.cardCvc);
|
||||
}, 50);
|
||||
if ([cardNumber, cardExpiry, cardCVC].some((element) => !element)) {
|
||||
this.logService.warning(
|
||||
`Some Stripe card elements are missing, retrying for attempt ${attempt}...`,
|
||||
);
|
||||
this.mountElements(attempt + 1);
|
||||
} else {
|
||||
cardNumber.mount(this.elementIds.cardNumber);
|
||||
cardExpiry.mount(this.elementIds.cardExpiry);
|
||||
cardCVC.mount(this.elementIds.cardCvc);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,8 +137,9 @@ export class StripeService {
|
||||
async setupBankAccountPaymentMethod(
|
||||
clientSecret: string,
|
||||
{ accountHolderName, routingNumber, accountNumber, accountHolderType }: BankAccount,
|
||||
billingDetails?: { country: string; postalCode: string },
|
||||
): Promise<string> {
|
||||
const result = await this.stripe.confirmUsBankAccountSetup(clientSecret, {
|
||||
const request: SetupBankAccountRequest = {
|
||||
payment_method: {
|
||||
us_bank_account: {
|
||||
routing_number: routingNumber,
|
||||
@@ -93,7 +150,16 @@ export class StripeService {
|
||||
name: accountHolderName,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (billingDetails) {
|
||||
request.payment_method.billing_details.address = {
|
||||
country: billingDetails.country,
|
||||
postal_code: billingDetails.postalCode,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.stripe.confirmUsBankAccountSetup(clientSecret, request);
|
||||
if (result.error || (result.setupIntent && result.setupIntent.status !== "requires_action")) {
|
||||
this.logService.error(result.error);
|
||||
throw result.error;
|
||||
@@ -107,13 +173,25 @@ export class StripeService {
|
||||
* thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}.
|
||||
* @returns The ID of the newly created PaymentMethod.
|
||||
*/
|
||||
async setupCardPaymentMethod(clientSecret: string): Promise<string> {
|
||||
async setupCardPaymentMethod(
|
||||
clientSecret: string,
|
||||
billingDetails?: { country: string; postalCode: string },
|
||||
): Promise<string> {
|
||||
const cardNumber = this.elements.getElement("cardNumber");
|
||||
const result = await this.stripe.confirmCardSetup(clientSecret, {
|
||||
const request: SetupCardRequest = {
|
||||
payment_method: {
|
||||
card: cardNumber,
|
||||
},
|
||||
});
|
||||
};
|
||||
if (billingDetails) {
|
||||
request.payment_method.billing_details = {
|
||||
address: {
|
||||
country: billingDetails.country,
|
||||
postal_code: billingDetails.postalCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
const result = await this.stripe.confirmCardSetup(clientSecret, request);
|
||||
if (result.error || (result.setupIntent && result.setupIntent.status !== "succeeded")) {
|
||||
this.logService.error(result.error);
|
||||
throw result.error;
|
||||
|
||||
@@ -11,6 +11,8 @@ import { BillingSourceResponse } from "@bitwarden/common/billing/models/response
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
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 { DialogService } from "@bitwarden/components";
|
||||
|
||||
@@ -28,6 +30,7 @@ export class TrialFlowService {
|
||||
private router: Router,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
checkForOrgsWithUpcomingPaymentIssues(
|
||||
organization: Organization,
|
||||
@@ -131,7 +134,11 @@ export class TrialFlowService {
|
||||
}
|
||||
|
||||
private async navigateToPaymentMethod(orgId: string) {
|
||||
await this.router.navigate(["organizations", `${orgId}`, "billing", "payment-method"], {
|
||||
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
|
||||
);
|
||||
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
|
||||
await this.router.navigate(["organizations", `${orgId}`, "billing", route], {
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
queryParams: { launchPaymentModalAutomatically: true },
|
||||
});
|
||||
|
||||
@@ -18,7 +18,9 @@ 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";
|
||||
@@ -79,6 +81,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
protected syncService: SyncService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
const state = this.router.getCurrentNavigation()?.extras?.state;
|
||||
// incase the above state is undefined or null we use redundantState
|
||||
@@ -107,6 +110,14 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "country" | i18n }}</bit-label>
|
||||
<bit-select formControlName="country" autocomplete="country">
|
||||
<bit-select formControlName="country" autocomplete="country" data-testid="country">
|
||||
<bit-option
|
||||
*ngFor="let country of countryList"
|
||||
[value]="country.value"
|
||||
@@ -16,37 +16,67 @@
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="postalCode" autocomplete="postal-code" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<input bitInput type="text" formControlName="taxId" data-testid="tax-id" />
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<app-vertical-step
|
||||
label="Billing"
|
||||
[subLabel]="billingSubLabel"
|
||||
*ngIf="(trialPaymentOptional$ | async) && trialLength === 0 && !isSecretsManagerFree"
|
||||
*ngIf="showBillingStep$ | async"
|
||||
>
|
||||
<app-trial-billing-step
|
||||
*ngIf="stepper.selectedIndex === 2"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { StepperSelectionEvent } from "@angular/cdk/stepper";
|
||||
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom, Subject, switchMap, takeUntil } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import {
|
||||
InputPasswordFlow,
|
||||
@@ -18,6 +18,7 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import {
|
||||
OrganizationBillingServiceAbstraction as OrganizationBillingService,
|
||||
OrganizationInformation,
|
||||
@@ -31,7 +32,6 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { AcceptOrganizationInviteService } from "../../../auth/organization-invite/accept-organization.service";
|
||||
import {
|
||||
OrganizationCreatedEvent,
|
||||
SubscriptionProduct,
|
||||
@@ -101,6 +101,9 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
protected trialPaymentOptional$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.TrialPaymentOptional,
|
||||
);
|
||||
protected allowTrialLengthZero$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.AllowTrialLengthZero,
|
||||
);
|
||||
|
||||
constructor(
|
||||
protected router: Router,
|
||||
@@ -112,7 +115,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
private i18nService: I18nService,
|
||||
private routerService: RouterService,
|
||||
private organizationBillingService: OrganizationBillingService,
|
||||
private acceptOrganizationInviteService: AcceptOrganizationInviteService,
|
||||
private organizationInviteService: OrganizationInviteService,
|
||||
private toastService: ToastService,
|
||||
private registrationFinishService: RegistrationFinishService,
|
||||
private validationService: ValidationService,
|
||||
@@ -171,7 +174,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
this.setupFamilySponsorship(qParams.sponsorshipToken);
|
||||
});
|
||||
|
||||
const invite = await this.acceptOrganizationInviteService.getOrganizationInvite();
|
||||
const invite = await this.organizationInviteService.getOrganizationInvite();
|
||||
let policies: Policy[] | null = null;
|
||||
|
||||
if (invite != null) {
|
||||
@@ -334,6 +337,18 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
return this.productTier;
|
||||
}
|
||||
|
||||
readonly showBillingStep$ = combineLatest([
|
||||
this.trialPaymentOptional$,
|
||||
this.allowTrialLengthZero$,
|
||||
]).pipe(
|
||||
map(([trialPaymentOptional, allowTrialLengthZero]) => {
|
||||
return (
|
||||
(!trialPaymentOptional && !this.isSecretsManagerFree) ||
|
||||
(trialPaymentOptional && allowTrialLengthZero && this.trialLength === 0)
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
/** Create an organization unless the trial is for secrets manager */
|
||||
async conditionallyCreateOrganization(): Promise<void> {
|
||||
if (!this.isSecretsManagerFree) {
|
||||
|
||||
42
apps/web/src/app/billing/types/billable-entity.ts
Normal file
42
apps/web/src/app/billing/types/billable-entity.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { map } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
|
||||
export type BillableEntity =
|
||||
| { type: "account"; data: Account }
|
||||
| { type: "organization"; data: Organization }
|
||||
| { type: "provider"; data: Provider };
|
||||
|
||||
export const accountToBillableEntity = map<Account | null, BillableEntity>((account) => {
|
||||
if (!account) {
|
||||
throw new Error("Account not found");
|
||||
}
|
||||
return {
|
||||
type: "account",
|
||||
data: account,
|
||||
};
|
||||
});
|
||||
|
||||
export const organizationToBillableEntity = map<Organization | undefined, BillableEntity>(
|
||||
(organization) => {
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
return {
|
||||
type: "organization",
|
||||
data: organization,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const providerToBillableEntity = map<Provider | null, BillableEntity>((provider) => {
|
||||
if (!provider) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
return {
|
||||
type: "provider",
|
||||
data: provider,
|
||||
};
|
||||
});
|
||||
2
apps/web/src/app/billing/types/index.ts
Normal file
2
apps/web/src/app/billing/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./billable-entity";
|
||||
export * from "./free-trial";
|
||||
2
apps/web/src/app/billing/warnings/components/index.ts
Normal file
2
apps/web/src/app/billing/warnings/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./organization-free-trial-warning.component";
|
||||
export * from "./organization-reseller-renewal-warning.component";
|
||||
@@ -6,15 +6,13 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import {
|
||||
FreeTrialWarning,
|
||||
OrganizationWarningsService,
|
||||
} from "../services/organization-warnings.service";
|
||||
import { OrganizationWarningsService } from "../services";
|
||||
import { OrganizationFreeTrialWarning } from "../types";
|
||||
|
||||
@Component({
|
||||
selector: "app-free-trial-warning",
|
||||
selector: "app-organization-free-trial-warning",
|
||||
template: `
|
||||
@let warning = freeTrialWarning$ | async;
|
||||
@let warning = warning$ | async;
|
||||
|
||||
@if (warning) {
|
||||
<bit-banner
|
||||
@@ -39,17 +37,19 @@ import {
|
||||
`,
|
||||
imports: [AnchorLinkDirective, AsyncPipe, BannerComponent, I18nPipe],
|
||||
})
|
||||
export class FreeTrialWarningComponent implements OnInit {
|
||||
export class OrganizationFreeTrialWarningComponent implements OnInit {
|
||||
@Input({ required: true }) organization!: Organization;
|
||||
@Output() clicked = new EventEmitter<void>();
|
||||
|
||||
freeTrialWarning$!: Observable<FreeTrialWarning>;
|
||||
warning$!: Observable<OrganizationFreeTrialWarning>;
|
||||
|
||||
constructor(private organizationWarningsService: OrganizationWarningsService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.freeTrialWarning$ = this.organizationWarningsService.getFreeTrialWarning$(
|
||||
this.organization,
|
||||
);
|
||||
this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization);
|
||||
}
|
||||
|
||||
refresh = () => {
|
||||
this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization, true);
|
||||
};
|
||||
}
|
||||
@@ -5,15 +5,13 @@ import { Observable } from "rxjs";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { BannerComponent } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
OrganizationWarningsService,
|
||||
ResellerRenewalWarning,
|
||||
} from "../services/organization-warnings.service";
|
||||
import { OrganizationWarningsService } from "../services";
|
||||
import { OrganizationResellerRenewalWarning } from "../types";
|
||||
|
||||
@Component({
|
||||
selector: "app-reseller-renewal-warning",
|
||||
selector: "app-organization-reseller-renewal-warning",
|
||||
template: `
|
||||
@let warning = resellerRenewalWarning$ | async;
|
||||
@let warning = warning$ | async;
|
||||
|
||||
@if (warning) {
|
||||
<bit-banner
|
||||
@@ -29,16 +27,14 @@ import {
|
||||
`,
|
||||
imports: [AsyncPipe, BannerComponent],
|
||||
})
|
||||
export class ResellerRenewalWarningComponent implements OnInit {
|
||||
export class OrganizationResellerRenewalWarningComponent implements OnInit {
|
||||
@Input({ required: true }) organization!: Organization;
|
||||
|
||||
resellerRenewalWarning$!: Observable<ResellerRenewalWarning>;
|
||||
warning$!: Observable<OrganizationResellerRenewalWarning>;
|
||||
|
||||
constructor(private organizationWarningsService: OrganizationWarningsService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.resellerRenewalWarning$ = this.organizationWarningsService.getResellerRenewalWarning$(
|
||||
this.organization,
|
||||
);
|
||||
this.warning$ = this.organizationWarningsService.getResellerRenewalWarning$(this.organization);
|
||||
}
|
||||
}
|
||||
1
apps/web/src/app/billing/warnings/services/index.ts
Normal file
1
apps/web/src/app/billing/warnings/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./organization-warnings.service";
|
||||
@@ -1,25 +1,20 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import {
|
||||
filter,
|
||||
from,
|
||||
lastValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
takeWhile,
|
||||
} from "rxjs";
|
||||
import { filter, from, lastValueFrom, map, Observable, switchMap, takeWhile } from "rxjs";
|
||||
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 { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
|
||||
import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.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 { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { openChangePlanDialog } from "@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component";
|
||||
|
||||
import { openChangePlanDialog } from "../../organizations/change-plan-dialog.component";
|
||||
import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning } from "../types";
|
||||
|
||||
const format = (date: Date) =>
|
||||
date.toLocaleDateString("en-US", {
|
||||
@@ -28,21 +23,12 @@ const format = (date: Date) =>
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export type FreeTrialWarning = {
|
||||
organization: Pick<Organization, "id" & "name">;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ResellerRenewalWarning = {
|
||||
type: "info" | "warning";
|
||||
message: string;
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class OrganizationWarningsService {
|
||||
private cache$ = new Map<OrganizationId, Observable<OrganizationWarningsResponse>>();
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
@@ -50,8 +36,11 @@ export class OrganizationWarningsService {
|
||||
private router: Router,
|
||||
) {}
|
||||
|
||||
getFreeTrialWarning$ = (organization: Organization): Observable<FreeTrialWarning> =>
|
||||
this.getWarning$(organization, (response) => response.freeTrial).pipe(
|
||||
getFreeTrialWarning$ = (
|
||||
organization: Organization,
|
||||
bypassCache: boolean = false,
|
||||
): Observable<OrganizationFreeTrialWarning> =>
|
||||
this.getWarning$(organization, (response) => response.freeTrial, bypassCache).pipe(
|
||||
map((warning) => {
|
||||
const { remainingTrialDays } = warning;
|
||||
|
||||
@@ -76,9 +65,12 @@ export class OrganizationWarningsService {
|
||||
}),
|
||||
);
|
||||
|
||||
getResellerRenewalWarning$ = (organization: Organization): Observable<ResellerRenewalWarning> =>
|
||||
this.getWarning$(organization, (response) => response.resellerRenewal).pipe(
|
||||
map((warning): ResellerRenewalWarning | null => {
|
||||
getResellerRenewalWarning$ = (
|
||||
organization: Organization,
|
||||
bypassCache: boolean = false,
|
||||
): Observable<OrganizationResellerRenewalWarning> =>
|
||||
this.getWarning$(organization, (response) => response.resellerRenewal, bypassCache).pipe(
|
||||
map((warning): OrganizationResellerRenewalWarning | null => {
|
||||
switch (warning.type) {
|
||||
case "upcoming": {
|
||||
return {
|
||||
@@ -116,8 +108,11 @@ export class OrganizationWarningsService {
|
||||
filter((result): result is NonNullable<typeof result> => result !== null),
|
||||
);
|
||||
|
||||
showInactiveSubscriptionDialog$ = (organization: Organization): Observable<void> =>
|
||||
this.getWarning$(organization, (response) => response.inactiveSubscription).pipe(
|
||||
showInactiveSubscriptionDialog$ = (
|
||||
organization: Organization,
|
||||
bypassCache: boolean = false,
|
||||
): Observable<void> =>
|
||||
this.getWarning$(organization, (response) => response.inactiveSubscription, bypassCache).pipe(
|
||||
switchMap(async (warning) => {
|
||||
switch (warning.resolution) {
|
||||
case "contact_provider": {
|
||||
@@ -142,8 +137,14 @@ 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", "payment-method"],
|
||||
["organizations", `${organization.id}`, "billing", route],
|
||||
{
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
},
|
||||
@@ -177,14 +178,15 @@ export class OrganizationWarningsService {
|
||||
}),
|
||||
);
|
||||
|
||||
private getResponse$ = (organization: Organization): Observable<OrganizationWarningsResponse> => {
|
||||
private getResponse$ = (
|
||||
organization: Organization,
|
||||
bypassCache: boolean = false,
|
||||
): Observable<OrganizationWarningsResponse> => {
|
||||
const existing = this.cache$.get(organization.id as OrganizationId);
|
||||
if (existing) {
|
||||
if (existing && !bypassCache) {
|
||||
return existing;
|
||||
}
|
||||
const response$ = from(this.organizationBillingApiService.getWarnings(organization.id)).pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
const response$ = from(this.organizationBillingApiService.getWarnings(organization.id));
|
||||
this.cache$.set(organization.id as OrganizationId, response$);
|
||||
return response$;
|
||||
};
|
||||
@@ -192,8 +194,9 @@ export class OrganizationWarningsService {
|
||||
private getWarning$ = <T>(
|
||||
organization: Organization,
|
||||
extract: (response: OrganizationWarningsResponse) => T | null | undefined,
|
||||
bypassCache: boolean = false,
|
||||
): Observable<T> =>
|
||||
this.getResponse$(organization).pipe(
|
||||
this.getResponse$(organization, bypassCache).pipe(
|
||||
map(extract),
|
||||
takeWhile((warning): warning is T => !!warning),
|
||||
take(1),
|
||||
1
apps/web/src/app/billing/warnings/types/index.ts
Normal file
1
apps/web/src/app/billing/warnings/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./organization-warnings";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
|
||||
export type OrganizationFreeTrialWarning = {
|
||||
organization: Pick<Organization, "id" & "name">;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type OrganizationResellerRenewalWarning = {
|
||||
type: "info" | "warning";
|
||||
message: string;
|
||||
};
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
OrganizationUserApiService,
|
||||
CollectionService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth/device-management/default-device-management-component.service";
|
||||
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
|
||||
import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password";
|
||||
import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
|
||||
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
||||
import {
|
||||
@@ -34,7 +37,6 @@ import {
|
||||
SsoComponentService,
|
||||
LoginDecryptionOptionsService,
|
||||
TwoFactorAuthDuoComponentService,
|
||||
ChangePasswordService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import {
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
@@ -52,6 +54,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
@@ -62,6 +65,7 @@ import {
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Urls,
|
||||
@@ -107,6 +111,7 @@ import {
|
||||
} from "@bitwarden/key-management";
|
||||
import { LockComponentService } from "@bitwarden/key-management-ui";
|
||||
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
|
||||
import { WebOrganizationInviteService } from "@bitwarden/web-vault/app/auth/core/services/organization-invite/web-organization-invite.service";
|
||||
|
||||
import { flagEnabled } from "../../utils/flags";
|
||||
import { PolicyListService } from "../admin-console/core/policy-list.service";
|
||||
@@ -121,7 +126,6 @@ import {
|
||||
WebSetInitialPasswordService,
|
||||
} from "../auth";
|
||||
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
|
||||
import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
|
||||
import { HtmlStorageService } from "../core/html-storage.service";
|
||||
import { I18nService } from "../core/i18n.service";
|
||||
import { WebFileDownloadService } from "../core/web-file-download.service";
|
||||
@@ -245,17 +249,22 @@ const safeProviders: SafeProvider[] = [
|
||||
provide: CLIENT_TYPE,
|
||||
useValue: ClientType.Web,
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationInviteService,
|
||||
useClass: WebOrganizationInviteService,
|
||||
deps: [GlobalStateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: RegistrationFinishServiceAbstraction,
|
||||
useClass: WebRegistrationFinishService,
|
||||
deps: [
|
||||
KeyServiceAbstraction,
|
||||
AccountApiServiceAbstraction,
|
||||
AcceptOrganizationInviteService,
|
||||
OrganizationInviteService,
|
||||
PolicyApiServiceAbstraction,
|
||||
LogService,
|
||||
PolicyService,
|
||||
AccountService,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -273,12 +282,11 @@ const safeProviders: SafeProvider[] = [
|
||||
provide: SetPasswordJitService,
|
||||
useClass: WebSetPasswordJitService,
|
||||
deps: [
|
||||
ApiService,
|
||||
MasterPasswordApiService,
|
||||
KeyServiceAbstraction,
|
||||
EncryptService,
|
||||
I18nServiceAbstraction,
|
||||
KdfConfigService,
|
||||
KeyServiceAbstraction,
|
||||
MasterPasswordApiService,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
OrganizationApiServiceAbstraction,
|
||||
OrganizationUserApiService,
|
||||
@@ -299,7 +307,7 @@ const safeProviders: SafeProvider[] = [
|
||||
OrganizationApiServiceAbstraction,
|
||||
OrganizationUserApiService,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
AcceptOrganizationInviteService,
|
||||
OrganizationInviteService,
|
||||
RouterService,
|
||||
],
|
||||
}),
|
||||
@@ -312,7 +320,7 @@ const safeProviders: SafeProvider[] = [
|
||||
provide: LoginComponentService,
|
||||
useClass: WebLoginComponentService,
|
||||
deps: [
|
||||
AcceptOrganizationInviteService,
|
||||
OrganizationInviteService,
|
||||
LogService,
|
||||
PolicyApiServiceAbstraction,
|
||||
InternalPolicyService,
|
||||
@@ -324,6 +332,7 @@ const safeProviders: SafeProvider[] = [
|
||||
SsoLoginServiceAbstraction,
|
||||
Router,
|
||||
AccountService,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -376,7 +385,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: LoginDecryptionOptionsService,
|
||||
useClass: WebLoginDecryptionOptionsService,
|
||||
deps: [MessagingService, RouterService, AcceptOrganizationInviteService],
|
||||
deps: [MessagingService, RouterService, OrganizationInviteService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: IpcService,
|
||||
@@ -396,8 +405,14 @@ const safeProviders: SafeProvider[] = [
|
||||
MasterPasswordApiService,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
UserKeyRotationService,
|
||||
RouterService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DeviceManagementComponentServiceAbstraction,
|
||||
useClass: DefaultDeviceManagementComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -467,8 +467,20 @@ export class EventService {
|
||||
break;
|
||||
// Secrets Manager
|
||||
case EventType.Secret_Retrieved:
|
||||
msg = this.i18nService.t("accessedSecret", this.formatSecretId(ev));
|
||||
humanReadableMsg = this.i18nService.t("accessedSecret", this.getShortId(ev.secretId));
|
||||
msg = this.i18nService.t("accessedSecretWithId", this.formatSecretId(ev));
|
||||
humanReadableMsg = this.i18nService.t("accessedSecretWithId", this.getShortId(ev.secretId));
|
||||
break;
|
||||
case EventType.Secret_Created:
|
||||
msg = this.i18nService.t("createdSecretWithId", this.formatSecretId(ev));
|
||||
humanReadableMsg = this.i18nService.t("createdSecretWithId", this.getShortId(ev.secretId));
|
||||
break;
|
||||
case EventType.Secret_Deleted:
|
||||
msg = this.i18nService.t("deletedSecretWithId", this.formatSecretId(ev));
|
||||
humanReadableMsg = this.i18nService.t("deletedSecretWithId", this.getShortId(ev.secretId));
|
||||
break;
|
||||
case EventType.Secret_Edited:
|
||||
msg = this.i18nService.t("editedSecretWithId", this.formatSecretId(ev));
|
||||
humanReadableMsg = this.i18nService.t("editedSecretWithId", this.getShortId(ev.secretId));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -12,7 +12,7 @@ export class WebFileDownloadService implements FileDownloadService {
|
||||
download(request: FileDownloadRequest): void {
|
||||
const builder = new FileDownloadBuilder(request);
|
||||
const a = window.document.createElement("a");
|
||||
if (!this.platformUtilsService.isSafari()) {
|
||||
if (!this.platformUtilsService.supportsFileDownloads()) {
|
||||
a.rel = "noreferrer";
|
||||
a.target = "_blank";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
|
||||
import { WebPlatformUtilsService } from "./web-platform-utils.service";
|
||||
|
||||
describe("Web Platform Utils Service", () => {
|
||||
@@ -114,4 +116,91 @@ describe("Web Platform Utils Service", () => {
|
||||
expect(result).toBe("2022.10.2");
|
||||
});
|
||||
});
|
||||
describe("getDevice", () => {
|
||||
const originalUserAgent = navigator.userAgent;
|
||||
|
||||
const setUserAgent = (userAgent: string) => {
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
value: userAgent,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const setWindowProperties = (props?: Record<string, any>) => {
|
||||
if (!props) {
|
||||
return;
|
||||
}
|
||||
Object.keys(props).forEach((key) => {
|
||||
Object.defineProperty(window, key, {
|
||||
value: props[key],
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
// Reset to original after each test
|
||||
setUserAgent(originalUserAgent);
|
||||
});
|
||||
|
||||
const testData: {
|
||||
userAgent: string;
|
||||
expectedDevice: DeviceType;
|
||||
windowProps?: Record<string, any>;
|
||||
}[] = [
|
||||
{
|
||||
// DuckDuckGo macoOS browser v1.13
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15 Ddg/18.3.1",
|
||||
expectedDevice: DeviceType.DuckDuckGoBrowser,
|
||||
},
|
||||
// DuckDuckGo Windows browser v0.109.7, which does not present the Ddg suffix and is therefore detected as Edge
|
||||
{
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0",
|
||||
expectedDevice: DeviceType.EdgeBrowser,
|
||||
},
|
||||
{
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
expectedDevice: DeviceType.ChromeBrowser,
|
||||
windowProps: { chrome: {} }, // set window.chrome = {} to simulate Chrome
|
||||
},
|
||||
{
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0",
|
||||
expectedDevice: DeviceType.FirefoxBrowser,
|
||||
},
|
||||
{
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 13_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15",
|
||||
expectedDevice: DeviceType.SafariBrowser,
|
||||
},
|
||||
{
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/120.0.0.0 Chrome/120.0.0.0 Safari/537.36",
|
||||
expectedDevice: DeviceType.EdgeBrowser,
|
||||
},
|
||||
{
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.5481.65 Safari/537.36 OPR/95.0.4635.46",
|
||||
expectedDevice: DeviceType.OperaBrowser,
|
||||
},
|
||||
{
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.57 Safari/537.36 Vivaldi/6.5.3206.48",
|
||||
expectedDevice: DeviceType.VivaldiBrowser,
|
||||
},
|
||||
];
|
||||
|
||||
test.each(testData)(
|
||||
"returns $expectedDevice for User-Agent: $userAgent",
|
||||
({ userAgent, expectedDevice, windowProps }) => {
|
||||
setUserAgent(userAgent);
|
||||
setWindowProperties(windowProps);
|
||||
const result = webPlatformUtilsService.getDevice();
|
||||
expect(result).toBe(expectedDevice);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,13 @@ export class WebPlatformUtilsService implements PlatformUtilsService {
|
||||
this.browserCache = DeviceType.EdgeBrowser;
|
||||
} else if (navigator.userAgent.indexOf(" Vivaldi/") !== -1) {
|
||||
this.browserCache = DeviceType.VivaldiBrowser;
|
||||
} else if (
|
||||
// We are only detecting DuckDuckGo browser on macOS currently, as
|
||||
// it is not presenting the Ddg suffix on Windows. DuckDuckGo users
|
||||
// on Windows will be detected as Edge.
|
||||
navigator.userAgent.indexOf("Ddg") !== -1
|
||||
) {
|
||||
this.browserCache = DeviceType.DuckDuckGoBrowser;
|
||||
} else if (
|
||||
navigator.userAgent.indexOf(" Safari/") !== -1 &&
|
||||
navigator.userAgent.indexOf("Chrome") === -1
|
||||
@@ -83,6 +90,10 @@ export class WebPlatformUtilsService implements PlatformUtilsService {
|
||||
return this.getDevice() === DeviceType.SafariBrowser;
|
||||
}
|
||||
|
||||
isWebKit(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
isMacAppStore(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -120,6 +131,15 @@ export class WebPlatformUtilsService implements PlatformUtilsService {
|
||||
return true;
|
||||
}
|
||||
|
||||
supportsAutofill(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Safari support for blob downloads is inconsistent and requires workarounds
|
||||
supportsFileDownloads(): boolean {
|
||||
return !(this.getDevice() === DeviceType.SafariBrowser);
|
||||
}
|
||||
|
||||
showToast(
|
||||
type: "error" | "success" | "warning" | "info",
|
||||
title: string,
|
||||
|
||||
@@ -16,8 +16,7 @@ export default {
|
||||
component: ReportCardComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [JslibModule, BadgeModule, IconModule, RouterTestingModule],
|
||||
declarations: [PremiumBadgeComponent],
|
||||
imports: [JslibModule, BadgeModule, IconModule, RouterTestingModule, PremiumBadgeComponent],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
|
||||
|
||||
@@ -18,8 +18,8 @@ export default {
|
||||
component: ReportListComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [JslibModule, BadgeModule, RouterTestingModule, IconModule],
|
||||
declarations: [PremiumBadgeComponent, ReportCardComponent],
|
||||
imports: [JslibModule, BadgeModule, RouterTestingModule, IconModule, PremiumBadgeComponent],
|
||||
declarations: [ReportCardComponent],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
|
||||
|
||||
@@ -6,13 +6,16 @@ import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import {
|
||||
EncryptedString,
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
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 { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
@@ -12,7 +13,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { EncryptionType, HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user