1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 11:24:07 +00:00

merge main, fix conflicts

This commit is contained in:
jng
2025-08-14 12:37:39 -04:00
1353 changed files with 39599 additions and 25767 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -82,5 +82,7 @@ function cloneCollection(
cloned.organizationId = collection.organizationId;
cloned.readOnly = collection.readOnly;
cloned.manage = collection.manage;
cloned.type = collection.type;
return cloned;
}

View File

@@ -10,6 +10,7 @@ import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstract
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { DialogService, ToastService } from "@bitwarden/components";
@@ -53,6 +54,7 @@ export class VaultFilterComponent
protected configService: ConfigService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherService: CipherService,
) {
super(
vaultFilterService,
@@ -65,6 +67,7 @@ export class VaultFilterComponent
configService,
accountService,
restrictedItemTypesService,
cipherService,
);
}
@@ -131,7 +134,7 @@ export class VaultFilterComponent
async buildAllFilters(): Promise<VaultFilterList> {
const builderFilter = {} as VaultFilterList;
builderFilter.typeFilter = await this.addTypeFilter(["favorites"]);
builderFilter.typeFilter = await this.addTypeFilter(["favorites"], this._organization?.id);
builderFilter.collectionFilter = await this.addCollectionFilter();
builderFilter.trashFilter = await this.addTrashFilter();
return builderFilter;

View File

@@ -5,7 +5,7 @@
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, switchMap } from "rxjs";
import {
CollectionAdminService,
@@ -14,6 +14,8 @@ import {
} from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
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 { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -99,6 +101,7 @@ export class VaultHeaderComponent {
private dialogService: DialogService,
private collectionAdminService: CollectionAdminService,
private router: Router,
private accountService: AccountService,
) {}
get title() {
@@ -199,7 +202,14 @@ export class VaultHeaderComponent {
async addCollection() {
if (this.organization.productTierType === ProductTierType.Free) {
const collections = await this.collectionAdminService.getAll(this.organization.id);
const collections = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.collectionAdminService.collectionAdminViews$(this.organization.id, userId),
),
),
);
if (collections.length === this.organization.maxCollections) {
this.showFreeOrgUpgradeDialog();
return;

View File

@@ -84,6 +84,7 @@
{{ trashCleanupWarning }}
</bit-callout>
<app-vault-items
#vaultItems
[ciphers]="ciphers"
[collections]="collections"
[allCollections]="allCollections"

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core";
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import {
BehaviorSubject,
@@ -29,6 +29,7 @@ import {
import {
CollectionAdminService,
CollectionAdminView,
CollectionService,
CollectionView,
Unassigned,
} from "@bitwarden/admin-console/common";
@@ -80,6 +81,7 @@ import {
} from "@bitwarden/vault";
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 { VaultItemsComponent } from "@bitwarden/web-vault/app/vault/components/vault-items/vault-items.component";
import { BillingNotificationService } from "../../../billing/services/billing-notification.service";
import {
@@ -203,6 +205,8 @@ export class VaultComponent implements OnInit, OnDestroy {
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
@ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent<CipherView>;
private readonly unpaidSubscriptionDialog$ = this.accountService.activeAccount$.pipe(
map((account) => account?.id),
switchMap((id) =>
@@ -264,6 +268,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private accountService: AccountService,
private billingNotificationService: BillingNotificationService,
private organizationWarningsService: OrganizationWarningsService,
private collectionService: CollectionService,
) {}
async ngOnInit() {
@@ -276,9 +281,16 @@ export class VaultComponent implements OnInit, OnDestroy {
);
const filter$ = this.routedVaultFilterService.filter$;
// FIXME: The RoutedVaultFilterModel uses `organizationId: Unassigned` to represent the individual vault,
// but that is never used in Admin Console. This function narrows the type so it doesn't pollute our code here,
// but really we should change to using our own vault filter model that only represents valid states in AC.
const isOrganizationId = (value: OrganizationId | Unassigned): value is OrganizationId =>
value !== Unassigned;
const organizationId$ = filter$.pipe(
map((filter) => filter.organizationId),
filter((filter) => filter !== undefined),
filter(isOrganizationId),
distinctUntilChanged(),
);
@@ -351,7 +363,12 @@ export class VaultComponent implements OnInit, OnDestroy {
this.allCollectionsWithoutUnassigned$ = this.refresh$.pipe(
switchMap(() => organizationId$),
switchMap((orgId) => this.collectionAdminService.getAll(orgId)),
switchMap((orgId) =>
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.collectionAdminService.collectionAdminViews$(orgId, userId)),
),
),
shareReplay({ refCount: false, bufferSize: 1 }),
);
@@ -371,9 +388,12 @@ export class VaultComponent implements OnInit, OnDestroy {
this.allCollectionsWithoutUnassigned$,
]).pipe(
map(([organizationId, allCollections]) => {
// FIXME: We should not assert that the Unassigned type is a CollectionId.
// Instead we should consider representing the Unassigned collection as a different object, given that
// it is not actually a collection.
const noneCollection = new CollectionAdminView();
noneCollection.name = this.i18nService.t("unassigned");
noneCollection.id = Unassigned;
noneCollection.id = Unassigned as CollectionId;
noneCollection.organizationId = organizationId;
return allCollections.concat(noneCollection);
}),
@@ -1133,6 +1153,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}
try {
await this.apiService.deleteCollection(this.organization?.id, collection.id);
await this.collectionService.delete([collection.id as CollectionId], this.userId);
this.toastService.showToast({
variant: "success",
title: null,
@@ -1417,6 +1438,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private refresh() {
this.refresh$.next();
this.vaultItemsComponent?.clearSelection();
}
private go(queryParams: any = null) {

View File

@@ -1,9 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Observable, switchMap } from "rxjs";
import { Observable, Subject, switchMap, takeUntil, scheduled, asyncScheduler } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations";
import {
getOrganizationById,
OrganizationService,
@@ -11,6 +13,8 @@ import {
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { IntegrationType } from "@bitwarden/common/enums";
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/shared.module";
@@ -30,12 +34,193 @@ import { Integration } from "../shared/components/integrations/models";
FilterIntegrationsPipe,
],
})
export class AdminConsoleIntegrationsComponent implements OnInit {
integrationsList: Integration[] = [];
export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
// integrationsList: Integration[] = [];
tabIndex: number;
organization$: Observable<Organization>;
isEventBasedIntegrationsEnabled: boolean = false;
private destroy$ = new Subject<void>();
// initialize the integrations list with default integrations
integrationsList: Integration[] = [
{
name: "AD FS",
linkURL: "https://bitwarden.com/help/saml-adfs/",
image: "../../../../../../../images/integrations/azure-active-directory.svg",
type: IntegrationType.SSO,
},
{
name: "Auth0",
linkURL: "https://bitwarden.com/help/saml-auth0/",
image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "AWS",
linkURL: "https://bitwarden.com/help/saml-aws/",
image: "../../../../../../../images/integrations/aws-color.svg",
imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/saml-azure/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.SSO,
},
{
name: "Duo",
linkURL: "https://bitwarden.com/help/saml-duo/",
image: "../../../../../../../images/integrations/logo-duo-color.svg",
type: IntegrationType.SSO,
},
{
name: "Google",
linkURL: "https://bitwarden.com/help/saml-google/",
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "JumpCloud",
linkURL: "https://bitwarden.com/help/saml-jumpcloud/",
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "KeyCloak",
linkURL: "https://bitwarden.com/help/saml-keycloak/",
image: "../../../../../../../images/integrations/logo-keycloak-icon.svg",
type: IntegrationType.SSO,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/saml-okta/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/saml-onelogin/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "PingFederate",
linkURL: "https://bitwarden.com/help/saml-pingfederate/",
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.SCIM,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/okta-scim-integration/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/onelogin-scim-integration/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "JumpCloud",
linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/",
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "Ping Identity",
linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/",
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
type: IntegrationType.SCIM,
},
{
name: "Active Directory",
linkURL: "https://bitwarden.com/help/ldap-directory/",
image: "../../../../../../../images/integrations/azure-active-directory.svg",
type: IntegrationType.BWDC,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/microsoft-entra-id/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.BWDC,
},
{
name: "Google Workspace",
linkURL: "https://bitwarden.com/help/workspace-directory/",
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
type: IntegrationType.BWDC,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/okta-directory/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.BWDC,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/onelogin-directory/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.BWDC,
},
{
name: "Splunk",
linkURL: "https://bitwarden.com/help/splunk-siem/",
image: "../../../../../../../images/integrations/logo-splunk-black.svg",
imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg",
type: IntegrationType.EVENT,
},
{
name: "Microsoft Sentinel",
linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/",
image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Rapid7",
linkURL: "https://bitwarden.com/help/rapid7-siem/",
image: "../../../../../../../images/integrations/logo-rapid7-black.svg",
imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg",
type: IntegrationType.EVENT,
},
{
name: "Elastic",
linkURL: "https://bitwarden.com/help/elastic-siem/",
image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Panther",
linkURL: "https://bitwarden.com/help/panther-siem/",
image: "../../../../../../../images/integrations/logo-panther-round-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Microsoft Intune",
linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/",
image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg",
type: IntegrationType.DEVICE,
},
];
ngOnInit(): void {
const orgId = this.route.snapshot.params.organizationId;
this.organization$ = this.route.params.pipe(
switchMap((params) =>
this.accountService.activeAccount$.pipe(
@@ -47,188 +232,56 @@ export class AdminConsoleIntegrationsComponent implements OnInit {
),
),
);
scheduled(this.orgIntegrationApiService.getOrganizationIntegrations(orgId), asyncScheduler)
.pipe(takeUntil(this.destroy$))
.subscribe((integrations) => {
// Update the integrations list with the fetched integrations
if (integrations && integrations.length > 0) {
integrations.forEach((integration) => {
const configJson = JSON.parse(integration.configuration || "{}");
const serviceName = configJson.service ?? "";
const existingIntegration = this.integrationsList.find((i) => i.name === serviceName);
if (existingIntegration) {
// if a configuration exists, then it is connected
existingIntegration.isConnected = !!integration.configuration;
existingIntegration.configuration = integration.configuration || "";
}
});
}
});
}
constructor(
private route: ActivatedRoute,
private organizationService: OrganizationService,
private accountService: AccountService,
private configService: ConfigService,
private orgIntegrationApiService: OrganizationIntegrationApiService,
) {
this.integrationsList = [
{
name: "AD FS",
linkURL: "https://bitwarden.com/help/saml-adfs/",
image: "../../../../../../../images/integrations/azure-active-directory.svg",
type: IntegrationType.SSO,
},
{
name: "Auth0",
linkURL: "https://bitwarden.com/help/saml-auth0/",
image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "AWS",
linkURL: "https://bitwarden.com/help/saml-aws/",
image: "../../../../../../../images/integrations/aws-color.svg",
imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/saml-azure/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.SSO,
},
{
name: "Duo",
linkURL: "https://bitwarden.com/help/saml-duo/",
image: "../../../../../../../images/integrations/logo-duo-color.svg",
type: IntegrationType.SSO,
},
{
name: "Google",
linkURL: "https://bitwarden.com/help/saml-google/",
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "JumpCloud",
linkURL: "https://bitwarden.com/help/saml-jumpcloud/",
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "KeyCloak",
linkURL: "https://bitwarden.com/help/saml-keycloak/",
image: "../../../../../../../images/integrations/logo-keycloak-icon.svg",
type: IntegrationType.SSO,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/saml-okta/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/saml-onelogin/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "PingFederate",
linkURL: "https://bitwarden.com/help/saml-pingfederate/",
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.SCIM,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/okta-scim-integration/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/onelogin-scim-integration/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "JumpCloud",
linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/",
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "Ping Identity",
linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/",
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
type: IntegrationType.SCIM,
},
{
name: "Active Directory",
linkURL: "https://bitwarden.com/help/ldap-directory/",
image: "../../../../../../../images/integrations/azure-active-directory.svg",
type: IntegrationType.BWDC,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/microsoft-entra-id/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.BWDC,
},
{
name: "Google Workspace",
linkURL: "https://bitwarden.com/help/workspace-directory/",
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
type: IntegrationType.BWDC,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/okta-directory/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.BWDC,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/onelogin-directory/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.BWDC,
},
{
name: "Splunk",
linkURL: "https://bitwarden.com/help/splunk-siem/",
image: "../../../../../../../images/integrations/logo-splunk-black.svg",
imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg",
this.configService
.getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations)
.pipe(takeUntil(this.destroy$))
.subscribe((isEnabled) => {
this.isEventBasedIntegrationsEnabled = isEnabled;
});
if (this.isEventBasedIntegrationsEnabled) {
this.integrationsList.push({
name: "Crowdstrike",
linkURL: "",
image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
type: IntegrationType.EVENT,
},
{
name: "Microsoft Sentinel",
linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/",
image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Rapid7",
linkURL: "https://bitwarden.com/help/rapid7-siem/",
image: "../../../../../../../images/integrations/logo-rapid7-black.svg",
imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg",
type: IntegrationType.EVENT,
},
{
name: "Elastic",
linkURL: "https://bitwarden.com/help/elastic-siem/",
image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Panther",
linkURL: "https://bitwarden.com/help/panther-siem/",
image: "../../../../../../../images/integrations/logo-panther-round-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Microsoft Intune",
linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/",
image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg",
type: IntegrationType.DEVICE,
},
];
description: "crowdstrikeEventIntegrationDesc",
isConnected: false,
canSetupConnection: true,
});
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
get IntegrationType(): typeof IntegrationType {

View File

@@ -28,6 +28,7 @@ import {
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -156,7 +157,11 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private orgCollections$ = from(this.collectionAdminService.getAll(this.organizationId)).pipe(
private orgCollections$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.collectionAdminService.collectionAdminViews$(this.organizationId, userId),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);

View File

@@ -11,6 +11,7 @@ import {
from,
lastValueFrom,
map,
Observable,
switchMap,
tap,
} from "rxjs";
@@ -25,10 +26,13 @@ import {
CollectionView,
} from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DialogService, TableDataSource, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { GroupDetailsView, InternalGroupApiService as GroupService } from "../core";
@@ -85,8 +89,8 @@ export class GroupsComponent {
protected searchControl = new FormControl("");
// Fixed sizes used for cdkVirtualScroll
protected rowHeight = 52;
protected rowHeightClass = `tw-h-[52px]`;
protected rowHeight = 50;
protected rowHeightClass = `tw-h-[50px]`;
protected ModalTabType = GroupAddEditTabType;
private refreshGroups$ = new BehaviorSubject<void>(null);
@@ -100,6 +104,8 @@ export class GroupsComponent {
private logService: LogService,
private collectionService: CollectionService,
private toastService: ToastService,
private keyService: KeyService,
private accountService: AccountService,
) {
this.route.params
.pipe(
@@ -244,16 +250,22 @@ export class GroupsComponent {
this.dataSource.data = this.dataSource.data.filter((g) => g !== groupRow);
}
private async toCollectionMap(response: ListResponse<CollectionResponse>) {
private toCollectionMap(
response: ListResponse<CollectionResponse>,
): Observable<Record<string, CollectionView>> {
const collections = response.data.map(
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse)),
);
const decryptedCollections = await this.collectionService.decryptMany(collections);
// Convert to an object using collection Ids as keys for faster name lookups
const collectionMap: Record<string, CollectionView> = {};
decryptedCollections.forEach((c) => (collectionMap[c.id] = c));
return collectionMap;
return this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
switchMap((orgKeys) => this.collectionService.decryptMany$(collections, orgKeys)),
map((collections) => {
const collectionMap: Record<string, CollectionView> = {};
collections.forEach((c) => (collectionMap[c.id] = c));
return collectionMap;
}),
);
}
}

View File

@@ -11,10 +11,13 @@ import {
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProviderUserBulkPublicKeyResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk-public-key.response";
import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -23,11 +26,13 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserService } from "../../services/organization-user/organization-user.service";
import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component";
import { BulkUserDetails } from "./bulk-status.component";
type BulkConfirmDialogParams = {
organizationId: string;
organization: Organization;
users: BulkUserDetails[];
};
@@ -36,7 +41,7 @@ type BulkConfirmDialogParams = {
standalone: false,
})
export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
organizationId: string;
organization: Organization;
organizationKey$: Observable<OrgKey>;
users: BulkUserDetails[];
@@ -47,13 +52,15 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
private organizationUserApiService: OrganizationUserApiService,
protected i18nService: I18nService,
private stateProvider: StateProvider,
private organizationUserService: OrganizationUserService,
private configService: ConfigService,
) {
super(keyService, encryptService, i18nService);
this.organizationId = dialogParams.organizationId;
this.organization = dialogParams.organization;
this.organizationKey$ = this.stateProvider.activeUserId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]),
map((organizationKeysById) => organizationKeysById[this.organization.id as OrganizationId]),
takeUntilDestroyed(),
);
this.users = dialogParams.users;
@@ -66,7 +73,7 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
ListResponse<OrganizationUserBulkPublicKeyResponse | ProviderUserBulkPublicKeyResponse>
> =>
await this.organizationUserApiService.postOrganizationUsersPublicKey(
this.organizationId,
this.organization.id,
this.filteredUsers.map((user) => user.id),
);
@@ -76,11 +83,19 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
protected postConfirmRequest = async (
userIdsWithKeys: { id: string; key: string }[],
): Promise<ListResponse<OrganizationUserBulkResponse | ProviderUserBulkResponse>> => {
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
return await this.organizationUserApiService.postOrganizationUserBulkConfirm(
this.organizationId,
request,
);
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
return await firstValueFrom(
this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys),
);
} else {
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
return await this.organizationUserApiService.postOrganizationUserBulkConfirm(
this.organization.id,
request,
);
}
};
static open(dialogService: DialogService, config: DialogConfig<BulkConfirmDialogParams>) {

View File

@@ -32,6 +32,7 @@ import {
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
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 { ProductTierType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -276,9 +277,16 @@ export class MemberDialogComponent implements OnDestroy {
),
);
const collections = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.collectionAdminService.collectionAdminViews$(this.params.organizationId, userId),
),
);
combineLatest({
organization: this.organization$,
collections: this.collectionAdminService.getAll(this.params.organizationId),
collections,
userDetails: userDetails$,
groups: groups$,
})

View File

@@ -1,67 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [title]="'recoverAccount' | i18n" [subtitle]="data.name">
<ng-container bitDialogContent>
<bit-callout type="warning"
>{{ "resetPasswordLoggedOutWarning" | i18n: loggedOutWarningName }}
</bit-callout>
<auth-password-callout
[policy]="enforcedPolicyOptions"
message="resetPasswordMasterPasswordPolicyInEffect"
*ngIf="enforcedPolicyOptions"
>
</auth-password-callout>
<bit-form-field>
<bit-label>
{{ "newPassword" | i18n }}
</bit-label>
<input
id="newPassword"
bitInput
[type]="showPassword ? 'text' : 'password'"
name="NewPassword"
formControlName="newPassword"
required
appInputVerbatim
autocomplete="new-password"
/>
<button
type="button"
bitIconButton="bwi-generate"
bitSuffix
[appA11yTitle]="'generatePassword' | i18n"
(click)="generatePassword()"
></button>
<button
type="button"
bitSuffix
[bitIconButton]="showPassword ? 'bwi-eye-slash' : 'bwi-eye'"
buttonType="secondary"
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
(click)="togglePassword()"
></button>
<button
type="button"
bitSuffix
bitIconButton="bwi-clone"
appA11yTitle="{{ 'copyPassword' | i18n }}"
(click)="copy()"
></button>
</bit-form-field>
<tools-password-strength
[password]="formGroup.value.newPassword"
[email]="data.email"
[showText]="true"
(passwordStrengthScore)="getStrengthScore($event)"
>
</tools-password-strength>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" bitFormButton type="submit">
{{ "save" | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -1,223 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, switchMap, takeUntil } from "rxjs";
import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { OrganizationUserResetPasswordService } from "../services/organization-user-reset-password/organization-user-reset-password.service";
/**
* Encapsulates a few key data inputs needed to initiate an account recovery
* process for the organization user in question.
*/
export type ResetPasswordDialogData = {
/**
* The organization user's full name
*/
name: string;
/**
* The organization user's email address
*/
email: string;
/**
* The `organizationUserId` for the user
*/
id: string;
/**
* The organization's `organizationId`
*/
organizationId: OrganizationId;
};
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum ResetPasswordDialogResult {
Ok = "ok",
}
/**
* Used in a dialog for initiating the account recovery process against a
* given organization user. An admin will access this form when they want to
* reset a user's password and log them out of sessions.
*
* @deprecated Use the `AccountRecoveryDialogComponent` instead.
*/
@Component({
selector: "app-reset-password",
templateUrl: "reset-password.component.html",
standalone: false,
})
export class ResetPasswordComponent implements OnInit, OnDestroy {
formGroup = this.formBuilder.group({
newPassword: ["", Validators.required],
});
@ViewChild(PasswordStrengthV2Component) passwordStrengthComponent: PasswordStrengthV2Component;
enforcedPolicyOptions: MasterPasswordPolicyOptions;
showPassword = false;
passwordStrengthScore: number;
private destroy$ = new Subject<void>();
constructor(
@Inject(DIALOG_DATA) protected data: ResetPasswordDialogData,
private resetPasswordService: OrganizationUserResetPasswordService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private policyService: PolicyService,
private logService: LogService,
private dialogService: DialogService,
private toastService: ToastService,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<ResetPasswordDialogResult>,
private accountService: AccountService,
) {}
async ngOnInit() {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
takeUntil(this.destroy$),
)
.subscribe(
(enforcedPasswordPolicyOptions) =>
(this.enforcedPolicyOptions = enforcedPasswordPolicyOptions),
);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
get loggedOutWarningName() {
return this.data.name != null ? this.data.name : this.i18nService.t("thisUser");
}
async generatePassword() {
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
this.formGroup.patchValue({
newPassword: await this.passwordGenerationService.generatePassword(options),
});
this.passwordStrengthComponent.updatePasswordStrength(this.formGroup.value.newPassword);
}
togglePassword() {
this.showPassword = !this.showPassword;
document.getElementById("newPassword").focus();
}
copy() {
const value = this.formGroup.value.newPassword;
if (value == null) {
return;
}
this.platformUtilsService.copyToClipboard(value, { window: window });
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("valueCopied", this.i18nService.t("password")),
});
}
submit = async () => {
// Validation
if (this.formGroup.value.newPassword == null || this.formGroup.value.newPassword === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordRequired"),
});
return false;
}
if (this.formGroup.value.newPassword.length < Utils.minimumPasswordLength) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordMinlength", Utils.minimumPasswordLength),
});
return false;
}
if (
this.enforcedPolicyOptions != null &&
!this.policyService.evaluateMasterPassword(
this.passwordStrengthScore,
this.formGroup.value.newPassword,
this.enforcedPolicyOptions,
)
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordPolicyRequirementsNotMet"),
});
return;
}
if (this.passwordStrengthScore < 3) {
const result = await this.dialogService.openSimpleDialog({
title: { key: "weakMasterPassword" },
content: { key: "weakMasterPasswordDesc" },
type: "warning",
});
if (!result) {
return false;
}
}
try {
await this.resetPasswordService.resetMasterPassword(
this.formGroup.value.newPassword,
this.data.email,
this.data.id,
this.data.organizationId,
);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("resetPasswordSuccess"),
});
} catch (e) {
this.logService.error(e);
}
this.dialogRef.close(ResetPasswordDialogResult.Ok);
};
getStrengthScore(result: number) {
this.passwordStrengthScore = result;
}
static open = (dialogService: DialogService, input: DialogConfig<ResetPasswordDialogData>) => {
return dialogService.open<ResetPasswordDialogResult>(ResetPasswordComponent, input);
};
}

View File

@@ -1,3 +1,8 @@
<app-organization-free-trial-warning
[organization]="organization"
(clicked)="navigateToPaymentMethod()"
>
</app-organization-free-trial-warning>
<app-header>
<bit-search
class="tw-grow"

View File

@@ -13,6 +13,7 @@ import {
Observable,
shareReplay,
switchMap,
tap,
} from "rxjs";
import {
@@ -61,6 +62,7 @@ import {
ChangePlanDialogResultType,
openChangePlanDialog,
} from "../../../billing/organizations/change-plan-dialog.component";
import { OrganizationWarningsService } from "../../../billing/warnings/services";
import { BaseMembersComponent } from "../../common/base-members.component";
import { PeopleTableDataSource } from "../../common/people-table-data-source";
import { GroupApiService } from "../core";
@@ -83,10 +85,6 @@ import {
openUserAddEditDialog,
} from "./components/member-dialog";
import { isFixedSeatPlan } from "./components/member-dialog/validators/org-seat-limit-reached.validator";
import {
ResetPasswordComponent,
ResetPasswordDialogResult,
} from "./components/reset-password.component";
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
import { OrganizationUserService } from "./services/organization-user/organization-user.service";
@@ -113,8 +111,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
protected showUserManagementControls$: Observable<boolean>;
// Fixed sizes used for cdkVirtualScroll
protected rowHeight = 69;
protected rowHeightClass = `tw-h-[69px]`;
protected rowHeight = 66;
protected rowHeightClass = `tw-h-[66px]`;
private organizationUsersCount = 0;
@@ -148,6 +146,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private configService: ConfigService,
private organizationUserService: OrganizationUserService,
private organizationWarningsService: OrganizationWarningsService,
) {
super(
apiService,
@@ -201,7 +200,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.organization.canManageUsersPassword &&
!this.organization.hasPublicAndPrivateKeys
) {
const orgShareKey = await this.keyService.getOrgKey(this.organization.id);
const orgShareKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => orgKeys[this.organization.id] ?? null),
),
);
const orgKeys = await this.keyService.makeKeyPair(orgShareKey);
const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
const response = await this.organizationApiService.updateKeys(
@@ -247,6 +253,13 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.showUserManagementControls$ = organization$.pipe(
map((organization) => organization.canManageUsers),
);
organization$
.pipe(
takeUntilDestroyed(),
tap((org) => (this.organization = org)),
switchMap((org) => this.organizationWarningsService.showInactiveSubscriptionDialog$(org)),
)
.subscribe();
}
async getUsers(): Promise<OrganizationUserView[]> {
@@ -297,17 +310,30 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
* Retrieve a map of all collection IDs <-> names for the organization.
*/
async getCollectionNameMap() {
const collectionMap = new Map<string, string>();
const response = await this.apiService.getCollections(this.organization.id);
const collections = response.data.map(
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse)),
const response = from(this.apiService.getCollections(this.organization.id)).pipe(
map((res) =>
res.data.map((r) => new Collection(new CollectionData(r as CollectionDetailsResponse))),
),
);
const decryptedCollections = await this.collectionService.decryptMany(collections);
decryptedCollections.forEach((c) => collectionMap.set(c.id, c.name));
const decryptedCollections$ = combineLatest([
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
),
response,
]).pipe(
switchMap(([orgKeys, collections]) =>
this.collectionService.decryptMany$(collections, orgKeys),
),
map((collections) => {
const collectionMap = new Map<string, string>();
collections.forEach((c) => collectionMap.set(c.id, c.name));
return collectionMap;
}),
);
return collectionMap;
return await firstValueFrom(decryptedCollections$);
}
removeUser(id: string): Promise<void> {
@@ -334,7 +360,13 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.organizationUserService.confirmUser(this.organization, user, publicKey),
);
} else {
const orgKey = await this.keyService.getOrgKey(this.organization.id);
const orgKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => orgKeys[this.organization.id] ?? null),
),
);
const key = await this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey);
const request = new OrganizationUserConfirmRequest();
request.key = key.encryptedString;
@@ -702,7 +734,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
data: {
organizationId: this.organization.id,
organization: this.organization,
users: this.dataSource.getCheckedUsers(),
},
});
@@ -746,52 +778,32 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
async resetPassword(user: OrganizationUserView) {
const changePasswordRefactorFlag = await this.configService.getFeatureFlag(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
if (changePasswordRefactorFlag) {
if (!user || !user.email || !user.id) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("orgUserDetailsNotFound"),
});
this.logService.error("Org user details not found when attempting account recovery");
return;
}
const dialogRef = AccountRecoveryDialogComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
email: user.email,
organizationId: this.organization.id as OrganizationId,
organizationUserId: user.id,
},
if (!user || !user.email || !user.id) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("orgUserDetailsNotFound"),
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AccountRecoveryDialogResultType.Ok) {
await this.load();
}
this.logService.error("Org user details not found when attempting account recovery");
return;
}
const dialogRef = ResetPasswordComponent.open(this.dialogService, {
const dialogRef = AccountRecoveryDialogComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
email: user != null ? user.email : null,
email: user.email,
organizationId: this.organization.id as OrganizationId,
id: user != null ? user.id : null,
organizationUserId: user.id,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === ResetPasswordDialogResult.Ok) {
if (result === AccountRecoveryDialogResultType.Ok) {
await this.load();
}
return;
}
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
@@ -932,4 +944,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
async navigateToPaymentMethod() {
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 },
});
}
}

View File

@@ -5,6 +5,7 @@ import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-s
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { ScrollLayoutDirective } from "@bitwarden/components";
import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components";
import { LooseComponentsModule } from "../../../shared";
import { SharedOrganizationModule } from "../shared";
@@ -15,7 +16,6 @@ import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog.
import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component";
import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
import { UserDialogModule } from "./components/member-dialog";
import { ResetPasswordComponent } from "./components/reset-password.component";
import { MembersRoutingModule } from "./members-routing.module";
import { MembersComponent } from "./members.component";
@@ -29,6 +29,7 @@ import { MembersComponent } from "./members.component";
ScrollingModule,
PasswordStrengthV2Component,
ScrollLayoutDirective,
OrganizationFreeTrialWarningComponent,
],
declarations: [
BulkConfirmDialogComponent,
@@ -37,7 +38,6 @@ import { MembersComponent } from "./members.component";
BulkRestoreRevokeComponent,
BulkStatusComponent,
MembersComponent,
ResetPasswordComponent,
BulkDeleteDialogComponent,
],
})

View File

@@ -17,8 +17,9 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
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 { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key";
import { KdfType, KeyService } from "@bitwarden/key-management";
@@ -36,6 +37,8 @@ describe("OrganizationUserResetPasswordService", () => {
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let organizationApiService: MockProxy<OrganizationApiService>;
let i18nService: MockProxy<I18nService>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
beforeAll(() => {
keyService = mock<KeyService>();
@@ -44,6 +47,7 @@ describe("OrganizationUserResetPasswordService", () => {
organizationUserApiService = mock<OrganizationUserApiService>();
organizationApiService = mock<OrganizationApiService>();
i18nService = mock<I18nService>();
accountService = mockAccountServiceWith(mockUserId);
sut = new OrganizationUserResetPasswordService(
keyService,
@@ -52,6 +56,7 @@ describe("OrganizationUserResetPasswordService", () => {
organizationUserApiService,
organizationApiService,
i18nService,
accountService,
);
});
@@ -142,7 +147,10 @@ describe("OrganizationUserResetPasswordService", () => {
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
keyService.getOrgKey.mockResolvedValue(mockOrgKey);
keyService.orgKeys$.mockReturnValue(
of({ [mockOrgId]: mockOrgKey } as Record<OrganizationId, OrgKey>),
);
encryptService.decryptToBytes.mockResolvedValue(mockRandomBytes);
encryptService.rsaDecrypt.mockResolvedValue(mockRandomBytes);
@@ -170,7 +178,7 @@ describe("OrganizationUserResetPasswordService", () => {
});
it("should throw an error if the org key is null", async () => {
keyService.getOrgKey.mockResolvedValue(null);
keyService.orgKeys$.mockReturnValue(of(null));
await expect(
sut.resetMasterPassword(mockNewMP, mockEmail, mockOrgUserId, mockOrgId),
).rejects.toThrow();

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map, switchMap } from "rxjs";
import {
OrganizationUserApiService,
@@ -10,6 +10,8 @@ import {
} from "@bitwarden/admin-console/common";
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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
EncryptedString,
@@ -47,6 +49,7 @@ export class OrganizationUserResetPasswordService
private organizationUserApiService: OrganizationUserApiService,
private organizationApiService: OrganizationApiServiceAbstraction,
private i18nService: I18nService,
private accountService: AccountService,
) {}
/**
@@ -111,7 +114,14 @@ export class OrganizationUserResetPasswordService
}
// Decrypt Organization's encrypted Private Key with org key
const orgSymKey = await this.keyService.getOrgKey(orgId);
const orgSymKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => orgKeys[orgId as OrganizationId] ?? null),
),
);
if (orgSymKey == null) {
throw new Error("No org key found");
}

View File

@@ -0,0 +1,175 @@
import { TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import {
OrganizationUserConfirmRequest,
OrganizationUserBulkConfirmRequest,
OrganizationUserApiService,
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { OrganizationUserService } from "./organization-user.service";
describe("OrganizationUserService", () => {
let service: OrganizationUserService;
let keyService: jest.Mocked<KeyService>;
let encryptService: jest.Mocked<EncryptService>;
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
let accountService: jest.Mocked<AccountService>;
let i18nService: jest.Mocked<I18nService>;
const mockOrganization = new Organization();
mockOrganization.id = "org-123" as OrganizationId;
const mockOrganizationUser = new OrganizationUserView();
mockOrganizationUser.id = "user-123";
const mockPublicKey = new Uint8Array(64) as CsprngArray;
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
const mockEncryptedKey = { encryptedString: "encrypted-key" } as EncString;
const mockEncryptedCollectionName = { encryptedString: "encrypted-collection-name" } as EncString;
const mockDefaultCollectionName = "My Items";
const setupCommonMocks = () => {
keyService.orgKeys$.mockReturnValue(
of({ [mockOrganization.id]: mockOrgKey } as Record<OrganizationId, OrgKey>),
);
encryptService.encryptString.mockResolvedValue(mockEncryptedCollectionName);
i18nService.t.mockReturnValue(mockDefaultCollectionName);
};
beforeEach(() => {
keyService = {
orgKeys$: jest.fn(),
} as any;
encryptService = {
encryptString: jest.fn(),
encapsulateKeyUnsigned: jest.fn(),
} as any;
organizationUserApiService = {
postOrganizationUserConfirm: jest.fn(),
postOrganizationUserBulkConfirm: jest.fn(),
} as any;
accountService = {
activeAccount$: of({ id: "user-123" }),
} as any;
i18nService = {
t: jest.fn(),
} as any;
TestBed.configureTestingModule({
providers: [
OrganizationUserService,
{ provide: KeyService, useValue: keyService },
{ provide: EncryptService, useValue: encryptService },
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
{ provide: AccountService, useValue: accountService },
{ provide: I18nService, useValue: i18nService },
],
});
service = TestBed.inject(OrganizationUserService);
});
describe("confirmUser", () => {
beforeEach(() => {
setupCommonMocks();
encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey);
organizationUserApiService.postOrganizationUserConfirm.mockReturnValue(Promise.resolve());
});
it("should confirm a user successfully", (done) => {
service.confirmUser(mockOrganization, mockOrganizationUser, mockPublicKey).subscribe({
next: () => {
expect(i18nService.t).toHaveBeenCalledWith("myItems");
expect(encryptService.encryptString).toHaveBeenCalledWith(
mockDefaultCollectionName,
mockOrgKey,
);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
mockOrgKey,
mockPublicKey,
);
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
mockOrganization.id,
mockOrganizationUser.id,
{
key: mockEncryptedKey.encryptedString,
defaultUserCollectionName: mockEncryptedCollectionName.encryptedString,
} as OrganizationUserConfirmRequest,
);
done();
},
error: done,
});
});
});
describe("bulkConfirmUsers", () => {
const mockUserIdsWithKeys = [
{ id: "user-1", key: "key-1" },
{ id: "user-2", key: "key-2" },
];
const mockBulkResponse = {
data: [
{ id: "user-1", error: null } as OrganizationUserBulkResponse,
{ id: "user-2", error: null } as OrganizationUserBulkResponse,
],
} as ListResponse<OrganizationUserBulkResponse>;
beforeEach(() => {
setupCommonMocks();
organizationUserApiService.postOrganizationUserBulkConfirm.mockReturnValue(
Promise.resolve(mockBulkResponse),
);
});
it("should bulk confirm users successfully", (done) => {
service.bulkConfirmUsers(mockOrganization, mockUserIdsWithKeys).subscribe({
next: (response) => {
expect(i18nService.t).toHaveBeenCalledWith("myItems");
expect(encryptService.encryptString).toHaveBeenCalledWith(
mockDefaultCollectionName,
mockOrgKey,
);
expect(organizationUserApiService.postOrganizationUserBulkConfirm).toHaveBeenCalledWith(
mockOrganization.id,
new OrganizationUserBulkConfirmRequest(
mockUserIdsWithKeys,
mockEncryptedCollectionName.encryptedString,
),
);
expect(response).toEqual(mockBulkResponse);
done();
},
error: done,
});
});
});
});

View File

@@ -3,12 +3,15 @@ import { combineLatest, filter, map, Observable, switchMap } from "rxjs";
import {
OrganizationUserConfirmRequest,
OrganizationUserBulkConfirmRequest,
OrganizationUserApiService,
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
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 { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
@@ -41,11 +44,7 @@ export class OrganizationUserService {
user: OrganizationUserView,
publicKey: Uint8Array,
): Observable<void> {
const encryptedCollectionName$ = this.orgKey$(organization).pipe(
switchMap((orgKey) =>
this.encryptService.encryptString(this.i18nService.t("myItems"), orgKey),
),
);
const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization);
const encryptedKey$ = this.orgKey$(organization).pipe(
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
@@ -66,4 +65,31 @@ export class OrganizationUserService {
}),
);
}
bulkConfirmUsers(
organization: Organization,
userIdsWithKeys: { id: string; key: string }[],
): Observable<ListResponse<OrganizationUserBulkResponse>> {
return this.getEncryptedDefaultCollectionName$(organization).pipe(
switchMap((collectionName) => {
const request = new OrganizationUserBulkConfirmRequest(
userIdsWithKeys,
collectionName.encryptedString,
);
return this.organizationUserApiService.postOrganizationUserBulkConfirm(
organization.id,
request,
);
}),
);
}
private getEncryptedDefaultCollectionName$(organization: Organization) {
return this.orgKey$(organization).pipe(
switchMap((orgKey) =>
this.encryptService.encryptString(this.i18nService.t("myItems"), orgKey),
),
);
}
}

View File

@@ -1,12 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, Input, OnInit } from "@angular/core";
import { UntypedFormControl, UntypedFormGroup } from "@angular/forms";
import { Observable, of } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
export abstract class BasePolicy {
abstract name: string;
@@ -14,38 +14,56 @@ export abstract class BasePolicy {
abstract type: PolicyType;
abstract component: any;
display(organization: Organization) {
return true;
/**
* If true, the description will be reused in the policy edit modal. Set this to false if you
* have more complex requirements that you will implement in your template instead.
**/
showDescription: boolean = true;
display(organization: Organization, configService: ConfigService): Observable<boolean> {
return of(true);
}
}
@Directive()
export abstract class BasePolicyComponent implements OnInit {
@Input() policyResponse: PolicyResponse;
@Input() policy: BasePolicy;
@Input() policyResponse: PolicyResponse | undefined;
@Input() policy: BasePolicy | undefined;
enabled = new UntypedFormControl(false);
data: UntypedFormGroup = null;
data: UntypedFormGroup | undefined;
ngOnInit(): void {
this.enabled.setValue(this.policyResponse.enabled);
this.enabled.setValue(this.policyResponse?.enabled);
if (this.policyResponse.data != null) {
if (this.policyResponse?.data != null) {
this.loadData();
}
}
buildRequest() {
const request = new PolicyRequest();
request.enabled = this.enabled.value;
request.type = this.policy.type;
request.data = this.buildRequestData();
if (!this.policy) {
throw new Error("Policy was not found");
}
const request: PolicyRequest = {
type: this.policy.type,
enabled: this.enabled.value,
data: this.buildRequestData(),
};
return Promise.resolve(request);
}
/**
* Enable optional validation before sumitting a respose for policy submission
* */
confirm(): Promise<boolean> | boolean {
return true;
}
protected loadData() {
this.data.patchValue(this.policyResponse.data ?? {});
this.data?.patchValue(this.policyResponse?.data ?? {});
}
protected buildRequestData() {

View File

@@ -3,6 +3,7 @@ export { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export { DisableSendPolicy } from "./disable-send.component";
export { MasterPasswordPolicy } from "./master-password.component";
export { PasswordGeneratorPolicy } from "./password-generator.component";
export { vNextOrganizationDataOwnershipPolicy } from "./vnext-organization-data-ownership.component";
export { OrganizationDataOwnershipPolicy } from "./organization-data-ownership.component";
export { RequireSsoPolicy } from "./require-sso.component";
export { ResetPasswordPolicy } from "./reset-password.component";

View File

@@ -1,6 +1,10 @@
import { Component } from "@angular/core";
import { map, Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -9,6 +13,12 @@ export class OrganizationDataOwnershipPolicy extends BasePolicy {
description = "personalOwnershipPolicyDesc";
type = PolicyType.OrganizationDataOwnership;
component = OrganizationDataOwnershipPolicyComponent;
display(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)
.pipe(map((enabled) => !enabled));
}
}
@Component({

View File

@@ -1,38 +1,45 @@
<app-header>
@let organization = organization$ | async;
<button
bitBadge
class="!tw-align-middle"
(click)="changePlan(organization)"
*ngIf="isBreadcrumbingEnabled$ | async"
slot="title-suffix"
type="button"
variant="primary"
>
{{ "upgrade" | i18n }}
</button>
@if (isBreadcrumbingEnabled$ | async) {
<button
bitBadge
class="!tw-align-middle"
(click)="changePlan(organization)"
slot="title-suffix"
type="button"
variant="primary"
>
{{ "upgrade" | i18n }}
</button>
}
</app-header>
<bit-container>
<ng-container *ngIf="loading">
@if (loading) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<bit-table *ngIf="!loading">
<ng-template body>
<tr bitRow *ngFor="let p of policies">
<td bitCell *ngIf="p.display(organization)" ngPreserveWhitespaces>
<button type="button" bitLink (click)="edit(p)">{{ p.name | i18n }}</button>
<span bitBadge variant="success" *ngIf="policiesEnabledMap.get(p.type)">{{
"on" | i18n
}}</span>
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
</td>
</tr>
</ng-template>
</bit-table>
}
@if (!loading) {
<bit-table>
<ng-template body>
@for (p of policies; track p.name) {
@if (p.display(organization, configService) | async) {
<tr bitRow>
<td bitCell ngPreserveWhitespaces>
<button type="button" bitLink (click)="edit(p)">{{ p.name | i18n }}</button>
@if (policiesEnabledMap.get(p.type)) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
</td>
</tr>
}
}
</ng-template>
</bit-table>
}
</bit-container>

View File

@@ -15,7 +15,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import {
@@ -25,7 +24,7 @@ import {
import { All } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model";
import { PolicyListService } from "../../core/policy-list.service";
import { BasePolicy, RestrictedItemTypesPolicy } from "../policies";
import { BasePolicy } from "../policies";
import { CollectionDialogTabType } from "../shared/components/collection-dialog";
import { PolicyEditComponent, PolicyEditDialogResult } from "./policy-edit.component";
@@ -53,7 +52,7 @@ export class PoliciesComponent implements OnInit {
private policyListService: PolicyListService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private dialogService: DialogService,
private configService: ConfigService,
protected configService: ConfigService,
) {}
async ngOnInit() {
@@ -71,35 +70,31 @@ export class PoliciesComponent implements OnInit {
await this.load();
// Handle policies component launch from Event message
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
if (qParams.policyId != null) {
const policyIdFromEvents: string = qParams.policyId;
for (const orgPolicy of this.orgPolicies) {
if (orgPolicy.id === policyIdFromEvents) {
for (let i = 0; i < this.policies.length; i++) {
if (this.policies[i].type === orgPolicy.type) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.edit(this.policies[i]);
break;
this.route.queryParams
.pipe(first())
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
.subscribe(async (qParams) => {
if (qParams.policyId != null) {
const policyIdFromEvents: string = qParams.policyId;
for (const orgPolicy of this.orgPolicies) {
if (orgPolicy.id === policyIdFromEvents) {
for (let i = 0; i < this.policies.length; i++) {
if (this.policies[i].type === orgPolicy.type) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.edit(this.policies[i]);
break;
}
}
break;
}
break;
}
}
}
});
});
});
}
async load() {
if (
(await this.configService.getFeatureFlag(FeatureFlag.RemoveCardItemTypePolicy)) &&
this.policyListService.getPolicies().every((p) => !(p instanceof RestrictedItemTypesPolicy))
) {
this.policyListService.addPolicies([new RestrictedItemTypesPolicy()]);
}
const response = await this.policyApiService.getPolicies(this.organizationId);
this.orgPolicies = response.data != null && response.data.length > 0 ? response.data : [];
this.orgPolicies.forEach((op) => {

View File

@@ -22,7 +22,9 @@
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div [hidden]="loading">
<p bitTypography="body1">{{ policy.description | i18n }}</p>
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
<ng-template #policyForm></ng-template>
</div>
</ng-container>

View File

@@ -128,13 +128,20 @@ export class PolicyEditComponent implements AfterViewInit {
}
submit = async () => {
if ((await this.policyComponent.confirm()) == false) {
this.dialogRef.close();
return;
}
let request: PolicyRequest;
try {
request = await this.policyComponent.buildRequest();
} catch (e) {
this.toastService.showToast({ variant: "error", title: null, message: e.message });
return;
}
await this.policyApiService.putPolicy(this.data.organizationId, this.data.policy.type, request);
this.toastService.showToast({
variant: "success",

View File

@@ -1,7 +1,9 @@
import { Component } from "@angular/core";
import { of } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -11,8 +13,8 @@ export class RequireSsoPolicy extends BasePolicy {
type = PolicyType.RequireSso;
component = RequireSsoPolicyComponent;
display(organization: Organization) {
return organization.useSso;
display(organization: Organization, configService: ConfigService) {
return of(organization.useSso);
}
}

View File

@@ -1,6 +1,6 @@
import { Component, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, of } from "rxjs";
import {
getOrganizationById,
@@ -10,6 +10,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -19,8 +20,8 @@ export class ResetPasswordPolicy extends BasePolicy {
type = PolicyType.ResetPassword;
component = ResetPasswordPolicyComponent;
display(organization: Organization) {
return organization.useResetPassword;
display(organization: Organization, configService: ConfigService) {
return of(organization.useResetPassword);
}
}
@@ -52,6 +53,10 @@ export class ResetPasswordPolicyComponent extends BasePolicyComponent implements
throw new Error("No user found.");
}
if (!this.policyResponse) {
throw new Error("Policies not found");
}
const organization = await firstValueFrom(
this.organizationService
.organizations$(userId)

View File

@@ -1,6 +1,10 @@
import { Component } from "@angular/core";
import { Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -9,6 +13,10 @@ export class RestrictedItemTypesPolicy extends BasePolicy {
description = "restrictedItemTypePolicyDesc";
type = PolicyType.RestrictedItemTypes;
component = RestrictedItemTypesPolicyComponent;
display(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService.getFeatureFlag$(FeatureFlag.RemoveCardItemTypePolicy);
}
}
@Component({

View File

@@ -20,6 +20,9 @@ export class SingleOrgPolicyComponent extends BasePolicyComponent implements OnI
async ngOnInit() {
super.ngOnInit();
if (!this.policyResponse) {
throw new Error("Policies not found");
}
if (!this.policyResponse.canToggleState) {
this.enabled.disable();
}

View File

@@ -0,0 +1,57 @@
<p>
{{ "organizationDataOwnershipContent" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</p>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<ng-template #dialog>
<bit-simple-dialog background="alt">
<span bitDialogTitle>{{ "organizationDataOwnershipWarningTitle" | i18n }}</span>
<ng-container bitDialogContent>
<div class="tw-text-left tw-overflow-hidden">
{{ "organizationDataOwnershipWarningContentTop" | i18n }}
<div class="tw-flex tw-flex-col tw-p-2">
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
<li>
{{ "organizationDataOwnershipWarning1" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning2" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning3" | i18n }}
</li>
</ul>
</div>
{{ "organizationDataOwnershipWarningContentBottom" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</div>
</ng-container>
<ng-container bitDialogFooter>
<span class="tw-flex tw-gap-2">
<button bitButton buttonType="primary" [bitDialogClose]="true" type="submit">
{{ "continue" | i18n }}
</button>
<button bitButton buttonType="secondary" [bitDialogClose]="false" type="button">
{{ "cancel" | i18n }}
</button>
</span>
</ng-container>
</bit-simple-dialog>
</ng-template>

View File

@@ -0,0 +1,50 @@
import { Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
import { lastValueFrom, Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class vNextOrganizationDataOwnershipPolicy extends BasePolicy {
name = "organizationDataOwnership";
description = "organizationDataOwnershipDesc";
type = PolicyType.OrganizationDataOwnership;
component = vNextOrganizationDataOwnershipPolicyComponent;
showDescription = false;
override display(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation);
}
}
@Component({
selector: "vnext-policy-organization-data-ownership",
templateUrl: "vnext-organization-data-ownership.component.html",
standalone: true,
imports: [SharedModule],
})
export class vNextOrganizationDataOwnershipPolicyComponent
extends BasePolicyComponent
implements OnInit
{
constructor(private dialogService: DialogService) {
super();
}
@ViewChild("dialog", { static: true }) warningContent!: TemplateRef<unknown>;
override async confirm(): Promise<boolean> {
if (this.policyResponse?.enabled && !this.enabled.value) {
const dialogRef = this.dialogService.open(this.warningContent);
const result = await lastValueFrom(dialogRef.closed);
return Boolean(result);
}
return true;
}
}

View File

@@ -8,6 +8,7 @@ import {
firstValueFrom,
from,
lastValueFrom,
map,
of,
Subject,
switchMap,
@@ -28,6 +29,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
@@ -179,7 +181,13 @@ export class AccountComponent implements OnInit, OnDestroy {
// Backfill pub/priv key if necessary
if (!this.org.hasPublicAndPrivateKeys) {
const orgShareKey = await this.keyService.getOrgKey(this.organizationId);
const orgShareKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => orgKeys[this.organizationId as OrganizationId] ?? null),
),
);
const orgKeys = await this.keyService.makeKeyPair(orgShareKey);
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
}

View File

@@ -50,7 +50,7 @@
>
<td bitCell [ngSwitch]="item.type" class="tw-w-5/12">
<div class="tw-flex tw-items-center" *ngSwitchCase="itemType.Member">
<bit-avatar size="small" class="tw-mr-3" text="{{ item.labelName }}"></bit-avatar>
<i class="bwi tw-mr-3 {{ item.icon }}" aria-hidden="true"></i>
<div class="tw-flex tw-flex-col">
<div>
{{ item.labelName }}
@@ -58,7 +58,10 @@
{{ "invited" | i18n }}
</span>
</div>
<div class="tw-text-xs tw-text-muted" *ngIf="$any(item).status != 0">
<div
class="tw-text-xs tw-text-muted"
*ngIf="$any(item).status != 0 && item.labelName != $any(item).email"
>
{{ $any(item).email }}
</div>
</div>
@@ -77,10 +80,10 @@
<td bitCell *ngIf="permissionMode != 'hidden'">
<ng-container *ngIf="canEditItemPermission(item); else readOnlyPerm">
<bit-form-field>
<bit-label>{{ item.labelName }} {{ "permission" | i18n }}</bit-label>
<bit-select
bitInput
formControlName="permission"
appA11yTitle="{{ item.labelName }} {{ 'permission' | i18n }}"
[id]="'permission' + i"
(closed)="handleBlur()"
>

View File

@@ -26,7 +26,6 @@ import {
CollectionResponse,
CollectionView,
CollectionService,
Collection,
} from "@bitwarden/admin-console/common";
import {
getOrganizationById,
@@ -38,7 +37,9 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import {
DIALOG_DATA,
DialogConfig,
@@ -87,8 +88,8 @@ enum ButtonType {
}
export interface CollectionDialogParams {
collectionId?: string;
organizationId: string;
collectionId?: CollectionId;
organizationId: OrganizationId;
initialTab?: CollectionDialogTabType;
parentCollectionId?: string;
showOrgSelector?: boolean;
@@ -136,12 +137,11 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
externalId: { value: "", disabled: true },
parent: undefined as string | undefined,
access: [[] as AccessItemValue[]],
selectedOrg: "",
selectedOrg: "" as OrganizationId,
});
protected PermissionMode = PermissionMode;
protected showDeleteButton = false;
protected showAddAccessWarning = false;
protected collections: Collection[];
protected buttonDisplayName: ButtonType = ButtonType.Save;
private orgExceedingCollectionLimit!: Organization;
@@ -166,14 +166,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
async ngOnInit() {
// Opened from the individual vault
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
if (this.params.showOrgSelector) {
this.showOrgSelector = true;
this.formGroup.controls.selectedOrg.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((id) => this.loadOrg(id));
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
this.organizations$ = this.organizationService.organizations$(userId).pipe(
first(),
map((orgs) =>
@@ -195,9 +193,14 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
);
if (isBreadcrumbEventLogsEnabled) {
this.collections = await this.collectionService.getAll();
this.organizationSelected.setAsyncValidators(
freeOrgCollectionLimitValidator(this.organizations$, this.collections, this.i18nService),
freeOrgCollectionLimitValidator(
this.organizations$,
this.collectionService
.encryptedCollections$(userId)
.pipe(map((collections) => collections ?? [])),
this.i18nService,
),
);
this.formGroup.updateValueAndValidity();
}
@@ -212,7 +215,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
}
}),
filter(() => this.organizationSelected.errors?.cannotCreateCollections),
switchMap((value) => this.findOrganizationById(value)),
switchMap((organizationId) => this.organizations$.pipe(getById(organizationId))),
takeUntil(this.destroy$),
)
.subscribe((org) => {
@@ -222,11 +225,6 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
});
}
async findOrganizationById(orgId: string): Promise<Organization | undefined> {
const organizations = await firstValueFrom(this.organizations$);
return organizations.find((org) => org.id === orgId);
}
async loadOrg(orgId: string) {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const organization$ = this.organizationService
@@ -242,9 +240,15 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
return this.groupService.getAll(orgId);
}),
);
const collections = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.collectionAdminService.collectionAdminViews$(orgId, userId)),
);
combineLatest({
organization: organization$,
collections: this.collectionAdminService.getAll(orgId),
collections,
groups: groups$,
users: this.organizationUserApiService.getAllMiniUserDetails(orgId),
})
@@ -413,7 +417,8 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
collectionView.name = this.formGroup.controls.name.value;
}
const savedCollection = await this.collectionAdminService.save(collectionView);
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const savedCollection = await this.collectionAdminService.save(collectionView, userId);
this.toastService.showToast({
variant: "success",

View File

@@ -17,16 +17,40 @@
</div>
</div>
<div class="tw-p-5">
<h3 class="tw-text-main tw-text-lg tw-font-semibold">{{ name }}</h3>
<a
class="tw-block tw-mb-0 tw-font-bold hover:tw-no-underline focus:tw-outline-none after:tw-content-[''] after:tw-block after:tw-absolute after:tw-size-full after:tw-left-0 after:tw-top-0"
[href]="linkURL"
rel="noopener noreferrer"
target="_blank"
>
</a>
<span *ngIf="showNewBadge()" bitBadge class="tw-mt-3" variant="secondary">
{{ "new" | i18n }}
</span>
<h3 class="tw-text-main tw-text-lg tw-font-semibold">
{{ name }}
@if (showConnectedBadge()) {
<span class="tw-ml-3">
@if (isConnected) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
@if (!isConnected) {
<span bitBadge>{{ "off" | i18n }}</span>
}
</span>
}
</h3>
<p class="tw-mb-0">{{ description }}</p>
@if (canSetupConnection) {
<button type="button" class="tw-mt-3" bitButton (click)="setupConnection()">
<span>{{ "connectIntegrationButtonDesc" | i18n: name }}</span>
</button>
}
@if (linkURL) {
<a
class="tw-block tw-mb-0 tw-font-bold hover:tw-no-underline focus:tw-outline-none after:tw-content-[''] after:tw-block after:tw-absolute after:tw-size-full after:tw-left-0 after:tw-top-0"
[href]="linkURL"
rel="noopener noreferrer"
target="_blank"
>
</a>
}
@if (showNewBadge()) {
<span bitBadge class="tw-mt-3" variant="secondary">
{{ "new" | i18n }}
</span>
}
</div>
</div>

View File

@@ -1,12 +1,15 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
// eslint-disable-next-line no-restricted-imports
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
// FIXME: remove `src` and fix import
import { ToastService } from "@bitwarden/components";
// eslint-disable-next-line no-restricted-imports
import { SharedModule } from "@bitwarden/components/src/shared";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -16,6 +19,9 @@ import { IntegrationCardComponent } from "./integration-card.component";
describe("IntegrationCardComponent", () => {
let component: IntegrationCardComponent;
let fixture: ComponentFixture<IntegrationCardComponent>;
const mockI18nService = mock<I18nService>();
const activatedRoute = mock<ActivatedRoute>();
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
const systemTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
const usersPreferenceTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
@@ -23,26 +29,22 @@ describe("IntegrationCardComponent", () => {
beforeEach(async () => {
// reset system theme
systemTheme$.next(ThemeType.Light);
activatedRoute.snapshot = {
paramMap: {
get: jest.fn().mockReturnValue("test-organization-id"),
},
} as any;
await TestBed.configureTestingModule({
imports: [IntegrationCardComponent, SharedModule],
providers: [
{
provide: ThemeStateService,
useValue: { selectedTheme$: usersPreferenceTheme$ },
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: systemTheme$,
},
{
provide: I18nPipe,
useValue: mock<I18nPipe>(),
},
{
provide: I18nService,
useValue: mock<I18nService>(),
},
{ provide: ThemeStateService, useValue: { selectedTheme$: usersPreferenceTheme$ } },
{ provide: SYSTEM_THEME_OBSERVABLE, useValue: systemTheme$ },
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService },
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService },
{ provide: ToastService, useValue: mock<ToastService>() },
],
}).compileComponents();
});
@@ -55,6 +57,7 @@ describe("IntegrationCardComponent", () => {
component.image = "test-image.png";
component.linkURL = "https://example.com/";
mockI18nService.t.mockImplementation((key) => key);
fixture.detectChanges();
});
@@ -67,7 +70,7 @@ describe("IntegrationCardComponent", () => {
it("renders card body", () => {
const name = fixture.nativeElement.querySelector("h3");
expect(name.textContent).toBe("Integration Name");
expect(name.textContent).toContain("Integration Name");
});
it("assigns external rel attribute", () => {
@@ -182,4 +185,28 @@ describe("IntegrationCardComponent", () => {
});
});
});
describe("connected badge", () => {
it("shows connected badge when isConnected is true", () => {
component.isConnected = true;
expect(component.showConnectedBadge()).toBe(true);
});
it("does not show connected badge when isConnected is false", () => {
component.isConnected = false;
fixture.detectChanges();
const name = fixture.nativeElement.querySelector("h3 > span > span > span");
expect(name.textContent).toContain("off");
// when isConnected is true/false, the badge should be shown as on/off
// when isConnected is undefined, the badge should not be shown
expect(component.showConnectedBadge()).toBe(true);
});
it("does not show connected badge when isConnected is undefined", () => {
component.isConnected = undefined;
expect(component.showConnectedBadge()).toBe(false);
});
});
});

View File

@@ -9,13 +9,26 @@ import {
OnDestroy,
ViewChild,
} from "@angular/core";
import { Observable, Subject, combineLatest, takeUntil } from "rxjs";
import { ActivatedRoute } from "@angular/router";
import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
// eslint-disable-next-line no-restricted-imports
import {
OrganizationIntegrationType,
OrganizationIntegrationRequest,
OrganizationIntegrationResponse,
OrganizationIntegrationApiService,
} from "@bitwarden/bit-common/dirt/integrations/index";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../../../../shared/shared.module";
import { openHecConnectDialog } from "../integration-dialog/index";
import { Integration } from "../models";
@Component({
selector: "app-integration-card",
@@ -30,6 +43,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
@Input() image: string;
@Input() imageDarkMode?: string;
@Input() linkURL: string;
@Input() integrationSettings: Integration;
/** Adds relevant `rel` attribute to external links */
@Input() externalURL?: boolean;
@@ -41,11 +55,19 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
* @example "2024-12-31"
*/
@Input() newBadgeExpiration?: string;
@Input() description?: string;
@Input() isConnected?: boolean;
@Input() canSetupConnection?: boolean;
constructor(
private themeStateService: ThemeStateService,
@Inject(SYSTEM_THEME_OBSERVABLE)
private systemTheme$: Observable<ThemeType>,
private dialogService: DialogService,
private activatedRoute: ActivatedRoute,
private apiService: OrganizationIntegrationApiService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
ngAfterViewInit() {
@@ -93,4 +115,63 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
return expirationDate > new Date();
}
showConnectedBadge(): boolean {
return this.isConnected !== undefined;
}
async setupConnection() {
// invoke the dialog to connect the integration
const dialog = openHecConnectDialog(this.dialogService, {
data: {
settings: this.integrationSettings,
},
});
const result = await lastValueFrom(dialog.closed);
// the dialog was cancelled
if (!result || !result.success) {
return;
}
// save the integration
try {
const dbResponse = await this.saveHecIntegration(result.configuration);
this.isConnected = !!dbResponse.id;
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("failedToSaveIntegration"),
});
return;
}
}
async saveHecIntegration(configuration: string): Promise<OrganizationIntegrationResponse> {
const organizationId = this.activatedRoute.snapshot.paramMap.get(
"organizationId",
) as OrganizationId;
const request = new OrganizationIntegrationRequest(
OrganizationIntegrationType.Hec,
configuration,
);
const integrations = await this.apiService.getOrganizationIntegrations(organizationId);
const existingIntegration = integrations.find(
(i) => i.type === OrganizationIntegrationType.Hec,
);
if (existingIntegration) {
return await this.apiService.updateOrganizationIntegration(
organizationId,
existingIntegration.id,
request,
);
} else {
return await this.apiService.createOrganizationIntegration(organizationId, request);
}
}
}

View File

@@ -1,58 +0,0 @@
import { importProvidersFrom } from "@angular/core";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { PreloadedEnglishI18nModule } from "../../../../../../core/tests";
import { IntegrationCardComponent } from "./integration-card.component";
class MockThemeService implements Partial<ThemeStateService> {}
export default {
title: "Web/Integration Layout/Integration Card",
component: IntegrationCardComponent,
decorators: [
applicationConfig({
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
}),
moduleMetadata({
providers: [
{
provide: ThemeStateService,
useClass: MockThemeService,
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: of(ThemeTypes.Light),
},
],
}),
],
args: {
integrations: [],
},
} as Meta;
type Story = StoryObj<IntegrationCardComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<app-integration-card
[name]="name"
[image]="image"
[linkURL]="linkURL"
></app-integration-card>
`,
}),
args: {
name: "Bitwarden",
image: "/integrations/bitwarden-vertical-blue.svg",
linkURL: "https://bitwarden.com",
},
};

View File

@@ -0,0 +1,38 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading">
<span bitDialogTitle>
{{ "connectIntegrationButtonDesc" | i18n: connectInfo.settings.name }}
</span>
<div bitDialogContent class="tw-flex tw-flex-col tw-gap-4">
@if (loading) {
<ng-container #spinner>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</ng-container>
}
@if (!loading) {
<ng-container>
<bit-form-field>
<bit-label>{{ "url" | i18n }}</bit-label>
<input bitInput formControlName="url" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "bearerToken" | i18n }}</bit-label>
<input bitInput formControlName="bearerToken" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "index" | i18n }}</bit-label>
<input bitInput formControlName="index" />
</bit-form-field>
</ng-container>
}
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary" [disabled]="loading">
{{ "save" | i18n }}
</button>
<button type="button" bitButton bitDialogClose buttonType="secondary" [disabled]="loading">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,176 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { Integration } from "../../models";
import {
ConnectHecDialogComponent,
HecConnectDialogParams,
HecConnectDialogResult,
openHecConnectDialog,
} from "./connect-dialog-hec.component";
beforeAll(() => {
// Mock element.animate for jsdom
// the animate function is not available in jsdom, so we provide a mock implementation
// This is necessary for tests that rely on animations
// This mock does not perform any actual animations, it just provides a structure that allows tests
// to run without throwing errors related to missing animate function
if (!HTMLElement.prototype.animate) {
HTMLElement.prototype.animate = function () {
return {
play: () => {},
pause: () => {},
finish: () => {},
cancel: () => {},
reverse: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
onfinish: null,
oncancel: null,
startTime: 0,
currentTime: 0,
playbackRate: 1,
playState: "idle",
replaceState: "active",
effect: null,
finished: Promise.resolve(),
id: "",
remove: () => {},
timeline: null,
ready: Promise.resolve(),
} as unknown as Animation;
};
}
});
describe("ConnectDialogHecComponent", () => {
let component: ConnectHecDialogComponent;
let fixture: ComponentFixture<ConnectHecDialogComponent>;
let dialogRefMock = mock<DialogRef<HecConnectDialogResult>>();
const mockI18nService = mock<I18nService>();
const integrationMock: Integration = {
name: "Test Integration",
image: "test-image.png",
linkURL: "https://example.com",
imageDarkMode: "test-image-dark.png",
newBadgeExpiration: "2024-12-31",
description: "Test Description",
isConnected: false,
canSetupConnection: true,
type: IntegrationType.EVENT,
} as Integration;
const connectInfo: HecConnectDialogParams = { settings: integrationMock };
beforeEach(async () => {
dialogRefMock = mock<DialogRef<HecConnectDialogResult>>();
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule, SharedModule, BrowserAnimationsModule],
providers: [
FormBuilder,
{ provide: DIALOG_DATA, useValue: connectInfo },
{ provide: DialogRef, useValue: dialogRefMock },
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ConnectHecDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
mockI18nService.t.mockImplementation((key) => key);
});
it("should create the component", () => {
expect(component).toBeTruthy();
});
it("should initialize form with empty values", () => {
expect(component.formGroup.value).toEqual({
url: "",
bearerToken: "",
index: "",
service: "Test Integration",
});
});
it("should have required validators for all fields", () => {
component.formGroup.setValue({ url: "", bearerToken: "", index: "", service: "" });
expect(component.formGroup.valid).toBeFalsy();
component.formGroup.setValue({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
});
expect(component.formGroup.valid).toBeTruthy();
});
it("should invalidate url if not matching pattern", () => {
component.formGroup.setValue({
url: "ftp://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
});
expect(component.formGroup.valid).toBeFalsy();
component.formGroup.setValue({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
});
expect(component.formGroup.valid).toBeTruthy();
});
it("should call dialogRef.close with correct result on submit", async () => {
component.formGroup.setValue({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
});
await component.submit();
expect(dialogRefMock.close).toHaveBeenCalledWith({
integrationSettings: integrationMock,
configuration: JSON.stringify({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
}),
success: true,
error: null,
});
});
});
describe("openCrowdstrikeConnectDialog", () => {
it("should call dialogService.open with correct params", () => {
const dialogServiceMock = mock<DialogService>();
const config: DialogConfig<HecConnectDialogParams, DialogRef<HecConnectDialogResult>> = {
data: { settings: { name: "Test" } as Integration },
} as any;
openHecConnectDialog(dialogServiceMock, config);
expect(dialogServiceMock.open).toHaveBeenCalledWith(ConnectHecDialogComponent, config);
});
});

View File

@@ -0,0 +1,81 @@
import { Component, Inject, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { Integration } from "../../models";
export type HecConnectDialogParams = {
settings: Integration;
};
export interface HecConnectDialogResult {
integrationSettings: Integration;
configuration: string;
success: boolean;
error: string | null;
}
@Component({
templateUrl: "./connect-dialog-hec.component.html",
imports: [SharedModule],
})
export class ConnectHecDialogComponent implements OnInit {
loading = false;
formGroup = this.formBuilder.group({
url: ["", [Validators.required, Validators.pattern("https?://.+")]],
bearerToken: ["", Validators.required],
index: ["", Validators.required],
service: ["", Validators.required],
});
constructor(
@Inject(DIALOG_DATA) protected connectInfo: HecConnectDialogParams,
protected formBuilder: FormBuilder,
private dialogRef: DialogRef<HecConnectDialogResult>,
) {}
ngOnInit(): void {
const settings = this.getSettingsAsJson(this.connectInfo.settings.configuration ?? "");
if (settings) {
this.formGroup.patchValue({
url: settings?.url || "",
bearerToken: settings?.bearerToken || "",
index: settings?.index || "",
service: this.connectInfo.settings.name,
});
}
}
getSettingsAsJson(configuration: string) {
try {
return JSON.parse(configuration);
} catch {
return {};
}
}
submit = async (): Promise<void> => {
const formJson = this.formGroup.getRawValue();
const result: HecConnectDialogResult = {
integrationSettings: this.connectInfo.settings,
configuration: JSON.stringify(formJson),
success: true,
error: null,
};
this.dialogRef.close(result);
return;
};
}
export function openHecConnectDialog(
dialogService: DialogService,
config: DialogConfig<HecConnectDialogParams, DialogRef<HecConnectDialogResult>>,
) {
return dialogService.open<HecConnectDialogResult>(ConnectHecDialogComponent, config);
}

View File

@@ -0,0 +1 @@
export * from "./connect-dialog/connect-dialog-hec.component";

View File

@@ -13,6 +13,10 @@
[imageDarkMode]="integration.imageDarkMode"
[externalURL]="integration.type === IntegrationType.SDK"
[newBadgeExpiration]="integration.newBadgeExpiration"
[description]="integration.description | i18n"
[isConnected]="integration.isConnected"
[canSetupConnection]="integration.canSetupConnection"
[integrationSettings]="integration"
></app-integration-card>
</li>
</ul>

View File

@@ -1,14 +1,20 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ActivatedRoute } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
// eslint-disable-next-line no-restricted-imports
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services";
import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
// eslint-disable-next-line import/order
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
// FIXME: remove `src` and fix import
import { ToastService } from "@bitwarden/components";
// eslint-disable-next-line no-restricted-imports
import { SharedModule } from "@bitwarden/components/src/shared";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -21,6 +27,8 @@ import { IntegrationGridComponent } from "./integration-grid.component";
describe("IntegrationGridComponent", () => {
let component: IntegrationGridComponent;
let fixture: ComponentFixture<IntegrationGridComponent>;
const mockActivatedRoute = mock<ActivatedRoute>();
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
const integrations: Integration[] = [
{
name: "Integration 1",
@@ -37,6 +45,12 @@ describe("IntegrationGridComponent", () => {
];
beforeEach(() => {
mockActivatedRoute.snapshot = {
paramMap: {
get: jest.fn().mockReturnValue("test-organization-id"),
},
} as any;
TestBed.configureTestingModule({
imports: [IntegrationGridComponent, IntegrationCardComponent, SharedModule],
providers: [
@@ -56,6 +70,18 @@ describe("IntegrationGridComponent", () => {
provide: I18nService,
useValue: mock<I18nService>({ t: (key, p1) => key + " " + p1 }),
},
{
provide: ActivatedRoute,
useValue: mockActivatedRoute,
},
{
provide: OrganizationIntegrationApiService,
useValue: mockOrgIntegrationApiService,
},
{
provide: ToastService,
useValue: mock<ToastService>(),
},
],
});

View File

@@ -1,70 +0,0 @@
import { importProvidersFrom } from "@angular/core";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { IntegrationType } from "@bitwarden/common/enums";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { PreloadedEnglishI18nModule } from "../../../../../../core/tests";
import { IntegrationCardComponent } from "../integration-card/integration-card.component";
import { IntegrationGridComponent } from "../integration-grid/integration-grid.component";
class MockThemeService implements Partial<ThemeStateService> {}
export default {
title: "Web/Integration Layout/Integration Grid",
component: IntegrationGridComponent,
decorators: [
applicationConfig({
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
}),
moduleMetadata({
imports: [IntegrationCardComponent],
providers: [
{
provide: ThemeStateService,
useClass: MockThemeService,
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: of(ThemeTypes.Dark),
},
],
}),
],
} as Meta;
type Story = StoryObj<IntegrationGridComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<app-integration-grid [integrations]="integrations"></app-integration-grid>
`,
}),
args: {
integrations: [
{
name: "Card 1",
linkURL: "https://bitwarden.com",
image: "/integrations/bitwarden-vertical-blue.svg",
type: IntegrationType.SSO,
},
{
name: "Card 2",
linkURL: "https://bitwarden.com",
image: "/integrations/bitwarden-vertical-blue.svg",
type: IntegrationType.SDK,
},
{
name: "Card 3",
linkURL: "https://bitwarden.com",
image: "/integrations/bitwarden-vertical-blue.svg",
type: IntegrationType.SCIM,
},
],
},
};

View File

@@ -17,4 +17,8 @@ export type Integration = {
* @example "2024-12-31"
*/
newBadgeExpiration?: string;
description?: string;
isConnected?: boolean;
canSetupConnection?: boolean;
configuration?: string;
};

View File

@@ -18,7 +18,7 @@ describe("freeOrgCollectionLimitValidator", () => {
it("returns null if organization is not found", async () => {
const orgs: Organization[] = [];
const validator = freeOrgCollectionLimitValidator(of(orgs), [], i18nService);
const validator = freeOrgCollectionLimitValidator(of(orgs), of([]), i18nService);
const control = new FormControl("org-id");
const result: Observable<ValidationErrors> = validator(control) as Observable<ValidationErrors>;
@@ -28,7 +28,7 @@ describe("freeOrgCollectionLimitValidator", () => {
});
it("returns null if control is not an instance of FormControl", async () => {
const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService);
const validator = freeOrgCollectionLimitValidator(of([]), of([]), i18nService);
const control = {} as AbstractControl;
const result: Observable<ValidationErrors | null> = validator(
@@ -40,7 +40,7 @@ describe("freeOrgCollectionLimitValidator", () => {
});
it("returns null if control is not provided", async () => {
const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService);
const validator = freeOrgCollectionLimitValidator(of([]), of([]), i18nService);
const result: Observable<ValidationErrors | null> = validator(
undefined as any,
@@ -53,7 +53,7 @@ describe("freeOrgCollectionLimitValidator", () => {
it("returns null if organization has not reached collection limit (Observable)", async () => {
const org = { id: "org-id", maxCollections: 2 } as Organization;
const collections = [{ organizationId: "org-id" } as Collection];
const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService);
const validator = freeOrgCollectionLimitValidator(of([org]), of(collections), i18nService);
const control = new FormControl("org-id");
const result$ = validator(control) as Observable<ValidationErrors | null>;
@@ -65,7 +65,7 @@ describe("freeOrgCollectionLimitValidator", () => {
it("returns error if organization has reached collection limit (Observable)", async () => {
const org = { id: "org-id", maxCollections: 1 } as Organization;
const collections = [{ organizationId: "org-id" } as Collection];
const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService);
const validator = freeOrgCollectionLimitValidator(of([org]), of(collections), i18nService);
const control = new FormControl("org-id");
const result$ = validator(control) as Observable<ValidationErrors | null>;

View File

@@ -1,13 +1,14 @@
import { AbstractControl, AsyncValidatorFn, FormControl, ValidationErrors } from "@angular/forms";
import { map, Observable, of } from "rxjs";
import { combineLatest, map, Observable, of } from "rxjs";
import { Collection } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
export function freeOrgCollectionLimitValidator(
orgs: Observable<Organization[]>,
collections: Collection[],
organizations$: Observable<Organization[]>,
collections$: Observable<Collection[]>,
i18nService: I18nService,
): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
@@ -21,15 +22,16 @@ export function freeOrgCollectionLimitValidator(
return of(null);
}
return orgs.pipe(
map((organizations) => organizations.find((org) => org.id === orgId)),
map((org) => {
if (!org) {
return combineLatest([organizations$.pipe(getById(orgId)), collections$]).pipe(
map(([organization, collections]) => {
if (!organization) {
return null;
}
const orgCollections = collections.filter((c) => c.organizationId === org.id);
const hasReachedLimit = org.maxCollections === orgCollections.length;
const orgCollections = collections.filter(
(collection: Collection) => collection.organizationId === organization.id,
);
const hasReachedLimit = organization.maxCollections === orgCollections.length;
if (hasReachedLimit) {
return {

View File

@@ -35,12 +35,14 @@ import {
MasterPasswordPolicy,
PasswordGeneratorPolicy,
OrganizationDataOwnershipPolicy,
vNextOrganizationDataOwnershipPolicy,
RequireSsoPolicy,
ResetPasswordPolicy,
SendOptionsPolicy,
SingleOrgPolicy,
TwoFactorAuthenticationPolicy,
RemoveUnlockWithPinPolicy,
RestrictedItemTypesPolicy,
} from "./admin-console/organizations/policies";
const BroadcasterSubscriptionId = "AppComponent";
@@ -244,8 +246,10 @@ export class AppComponent implements OnDestroy, OnInit {
new SingleOrgPolicy(),
new RequireSsoPolicy(),
new OrganizationDataOwnershipPolicy(),
new vNextOrganizationDataOwnershipPolicy(),
new DisableSendPolicy(),
new SendOptionsPolicy(),
new RestrictedItemTypesPolicy(),
]);
}
@@ -285,7 +289,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.keyService.clearKeys(userId),
this.cipherService.clear(userId),
this.folderService.clear(userId),
this.collectionService.clear(userId),
this.biometricStateService.logout(userId),
]);

View File

@@ -3,7 +3,6 @@ export * from "./login";
export * from "./login-decryption-options";
export * from "./webauthn-login";
export * from "./password-management";
export * from "./set-password-jit";
export * from "./registration";
export * from "./two-factor-auth";
export * from "./link-sso.service";

View File

@@ -1,6 +1,5 @@
import { TestBed } from "@angular/core/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { DefaultLoginComponentService } from "@bitwarden/auth/angular";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
@@ -10,6 +9,7 @@ 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 { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
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";
@@ -87,26 +87,29 @@ describe("WebLoginComponentService", () => {
});
describe("getOrgPoliciesFromOrgInvite", () => {
const mockEmail = "test@example.com";
const orgInvite: OrganizationInvite = {
organizationId: "org-id",
token: "token",
email: mockEmail,
organizationUserId: "org-user-id",
initOrganization: false,
orgSsoIdentifier: "sso-id",
orgUserHasExistingUser: false,
organizationName: "org-name",
};
it("returns undefined if organization invite is null", async () => {
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
const result = await service.getOrgPoliciesFromOrgInvite();
const result = await service.getOrgPoliciesFromOrgInvite(mockEmail);
expect(result).toBeUndefined();
});
it("logs an error if getPoliciesByToken throws an error", async () => {
const error = new Error("Test error");
organizationInviteService.getOrganizationInvite.mockResolvedValue({
organizationId: "org-id",
token: "token",
email: "email",
organizationUserId: "org-user-id",
initOrganization: false,
orgSsoIdentifier: "sso-id",
orgUserHasExistingUser: false,
organizationName: "org-name",
});
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
policyApiService.getPoliciesByToken.mockRejectedValue(error);
await service.getOrgPoliciesFromOrgInvite();
await service.getOrgPoliciesFromOrgInvite(mockEmail);
expect(logService.error).toHaveBeenCalledWith(error);
});
@@ -121,16 +124,7 @@ describe("WebLoginComponentService", () => {
const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions();
resetPasswordPolicyOptions.autoEnrollEnabled = autoEnrollEnabled;
organizationInviteService.getOrganizationInvite.mockResolvedValue({
organizationId: "org-id",
token: "token",
email: "email",
organizationUserId: "org-user-id",
initOrganization: false,
orgSsoIdentifier: "sso-id",
orgUserHasExistingUser: false,
organizationName: "org-name",
});
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
policyApiService.getPoliciesByToken.mockResolvedValue(policies);
internalPolicyService.getResetPasswordPolicyOptions.mockReturnValue([
@@ -138,11 +132,11 @@ describe("WebLoginComponentService", () => {
resetPasswordPolicyEnabled,
]);
internalPolicyService.masterPasswordPolicyOptions$.mockReturnValue(
of(masterPasswordPolicyOptions),
internalPolicyService.combinePoliciesIntoMasterPasswordPolicyOptions.mockReturnValue(
masterPasswordPolicyOptions,
);
const result = await service.getOrgPoliciesFromOrgInvite();
const result = await service.getOrgPoliciesFromOrgInvite(mockEmail);
expect(result).toEqual({
policies: policies,
@@ -152,5 +146,40 @@ describe("WebLoginComponentService", () => {
});
},
);
describe("given the orgInvite email does not match the provided email", () => {
const mockMismatchedEmail = "mismatched@example.com";
it("should clear the login redirect URL and organization invite", async () => {
// Arrange
organizationInviteService.getOrganizationInvite.mockResolvedValue({
...orgInvite,
email: mockMismatchedEmail,
});
// Act
await service.getOrgPoliciesFromOrgInvite(mockEmail);
// Assert
expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalledTimes(1);
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1);
});
it("should log an error and return undefined", async () => {
// Arrange
organizationInviteService.getOrganizationInvite.mockResolvedValue({
...orgInvite,
email: mockMismatchedEmail,
});
// Act
const result = await service.getOrgPoliciesFromOrgInvite(mockEmail);
// Assert
expect(logService.error).toHaveBeenCalledWith(
`WebLoginComponentService.getOrgPoliciesFromOrgInvite: Email mismatch. Expected: ${mockMismatchedEmail}, Received: ${mockEmail}`,
);
expect(result).toBeUndefined();
});
});
});
});

View File

@@ -2,7 +2,6 @@
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, switchMap } from "rxjs";
import {
DefaultLoginComponentService,
@@ -11,13 +10,10 @@ 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";
@@ -70,10 +66,27 @@ export class WebLoginComponentService
return;
}
async getOrgPoliciesFromOrgInvite(): Promise<PasswordPolicies | undefined> {
async getOrgPoliciesFromOrgInvite(email: string): Promise<PasswordPolicies | undefined> {
const orgInvite = await this.organizationInviteService.getOrganizationInvite();
if (orgInvite != null) {
/**
* Check if the email on the org invite matches the email submitted in the login form. This is
* important because say userA at "userA@mail.com" clicks an emailed org invite link, but then
* on the login page form they change the email to "userB@mail.com". We don't want to apply the org
* invite in state to userB. Therefore we clear the login redirect url as well as the org invite,
* allowing userB to login as normal.
*/
if (orgInvite.email !== email.toLowerCase()) {
await this.routerService.getAndClearLoginRedirectUrl();
await this.organizationInviteService.clearOrganizationInvitation();
this.logService.error(
`WebLoginComponentService.getOrgPoliciesFromOrgInvite: Email mismatch. Expected: ${orgInvite.email}, Received: ${email}`,
);
return undefined;
}
let policies: Policy[];
try {
@@ -99,23 +112,8 @@ export class WebLoginComponentService
const isPolicyAndAutoEnrollEnabled =
resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled;
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),
),
),
);
}
const enforcedPasswordPolicyOptions =
this.policyService.combinePoliciesIntoMasterPasswordPolicyOptions(policies);
return {
policies,

View File

@@ -12,7 +12,6 @@ import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-a
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 { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
@@ -30,7 +29,6 @@ describe("WebRegistrationFinishService", () => {
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
let logService: MockProxy<LogService>;
let policyService: MockProxy<PolicyService>;
let configService: MockProxy<ConfigService>;
beforeEach(() => {
keyService = mock<KeyService>();
@@ -39,7 +37,6 @@ describe("WebRegistrationFinishService", () => {
policyApiService = mock<PolicyApiServiceAbstraction>();
logService = mock<LogService>();
policyService = mock<PolicyService>();
configService = mock<ConfigService>();
service = new WebRegistrationFinishService(
keyService,
@@ -48,7 +45,6 @@ describe("WebRegistrationFinishService", () => {
policyApiService,
logService,
policyService,
configService,
);
});
@@ -414,22 +410,4 @@ 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");
});
});
});

View File

@@ -14,12 +14,10 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.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 { KeyService } from "@bitwarden/key-management";
@@ -34,7 +32,6 @@ export class WebRegistrationFinishService
private policyApiService: PolicyApiServiceAbstraction,
private logService: LogService,
private policyService: PolicyService,
private configService: ConfigService,
) {
super(keyService, accountApiService);
}
@@ -79,18 +76,6 @@ 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,

View File

@@ -1 +0,0 @@
export * from "./web-set-password-jit.service";

View File

@@ -1,27 +0,0 @@
import { inject } from "@angular/core";
import {
DefaultSetPasswordJitService,
SetPasswordCredentials,
SetPasswordJitService,
} from "@bitwarden/auth/angular";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { RouterService } from "../../../../core/router.service";
export class WebSetPasswordJitService
extends DefaultSetPasswordJitService
implements SetPasswordJitService
{
routerService = inject(RouterService);
organizationInviteService = inject(OrganizationInviteService);
override async setPassword(credentials: SetPasswordCredentials) {
await super.setPassword(credentials);
// 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.organizationInviteService.clearOrganizationInvitation();
}
}

View File

@@ -2,23 +2,22 @@
// @ts-strict-ignore
import { MockProxy } from "jest-mock-extended";
import mock from "jest-mock-extended/lib/Mock";
import { of } from "rxjs";
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 { 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 { UserKey, MasterKey } from "@bitwarden/common/types/key";
import { UserKey, MasterKey, UserPrivateKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { KdfType, KeyService } from "@bitwarden/key-management";
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
import { EmergencyAccessType } from "../enums/emergency-access-type";
@@ -28,6 +27,7 @@ import {
EmergencyAccessGranteeDetailsResponse,
EmergencyAccessGrantorDetailsResponse,
EmergencyAccessTakeoverResponse,
EmergencyAccessViewResponse,
} from "../response/emergency-access.response";
import { EmergencyAccessApiService } from "./emergency-access-api.service";
@@ -38,11 +38,9 @@ describe("EmergencyAccessService", () => {
let apiService: MockProxy<ApiService>;
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let bulkEncryptService: MockProxy<BulkEncryptService>;
let cipherService: MockProxy<CipherService>;
let logService: MockProxy<LogService>;
let emergencyAccessService: EmergencyAccessService;
let configService: ConfigService;
const mockNewUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("trustedPublicKey")];
@@ -52,7 +50,6 @@ describe("EmergencyAccessService", () => {
apiService = mock<ApiService>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
bulkEncryptService = mock<BulkEncryptService>();
cipherService = mock<CipherService>();
logService = mock<LogService>();
@@ -61,10 +58,8 @@ describe("EmergencyAccessService", () => {
apiService,
keyService,
encryptService,
bulkEncryptService,
cipherService,
logService,
configService,
);
});
@@ -149,88 +144,306 @@ describe("EmergencyAccessService", () => {
});
});
describe("getViewOnlyCiphers", () => {
const params = {
id: "emergency-access-id",
activeUserId: Utils.newGuid() as UserId,
};
it("throws an error is the active user's private key isn't available", async () => {
keyService.userPrivateKey$.mockReturnValue(of(null));
await expect(
emergencyAccessService.getViewOnlyCiphers(params.id, params.activeUserId),
).rejects.toThrow("Active user does not have a private key, cannot get view only ciphers.");
});
it("should return decrypted and sorted ciphers", async () => {
const emergencyAccessViewResponse = {
keyEncrypted: "mockKeyEncrypted",
ciphers: [
{ id: "cipher1", name: "encryptedName1" },
{ id: "cipher2", name: "encryptedName2" },
],
} as EmergencyAccessViewResponse;
const mockEncryptedCipher1 = {
id: "cipher1",
decrypt: jest.fn().mockResolvedValue({ id: "cipher1", decrypted: true }),
};
const mockEncryptedCipher2 = {
id: "cipher2",
decrypt: jest.fn().mockResolvedValue({ id: "cipher2", decrypted: true }),
};
emergencyAccessViewResponse.ciphers.map = jest.fn().mockImplementation(() => {
return [mockEncryptedCipher1, mockEncryptedCipher2];
});
cipherService.getLocaleSortingFunction.mockReturnValue((a: any, b: any) =>
a.id.localeCompare(b.id),
);
emergencyAccessApiService.postEmergencyAccessView.mockResolvedValue(
emergencyAccessViewResponse,
);
const mockPrivateKey = new Uint8Array(64) as UserPrivateKey;
keyService.userPrivateKey$.mockReturnValue(of(mockPrivateKey));
const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(mockDecryptedGrantorUserKey);
const mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
const result = await emergencyAccessService.getViewOnlyCiphers(
params.id,
params.activeUserId,
);
expect(result).toEqual([
{ id: "cipher1", decrypted: true },
{ id: "cipher2", decrypted: true },
]);
expect(mockEncryptedCipher1.decrypt).toHaveBeenCalledWith(mockGrantorUserKey);
expect(mockEncryptedCipher2.decrypt).toHaveBeenCalledWith(mockGrantorUserKey);
expect(emergencyAccessApiService.postEmergencyAccessView).toHaveBeenCalledWith(params.id);
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(emergencyAccessViewResponse.keyEncrypted),
mockPrivateKey,
);
expect(cipherService.getLocaleSortingFunction).toHaveBeenCalled();
});
});
describe("takeover", () => {
const mockId = "emergencyAccessId";
const mockEmail = "emergencyAccessEmail";
const mockName = "emergencyAccessName";
const params = {
id: "emergencyAccessId",
masterPassword: "mockPassword",
email: "emergencyAccessEmail",
activeUserId: Utils.newGuid() as UserId,
};
const takeoverResponse = {
keyEncrypted: "EncryptedKey",
kdf: KdfType.PBKDF2_SHA256,
kdfIterations: 500,
} as EmergencyAccessTakeoverResponse;
const userPrivateKey = new Uint8Array(64) as UserPrivateKey;
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey;
const mockMasterKeyHash = "mockMasterKeyHash";
let mockGrantorUserKey: UserKey;
// must mock [UserKey, EncString] return from keyService.encryptUserKeyWithMasterKey
// where UserKey is the decrypted grantor user key
const mockMasterKeyEncryptedUserKey = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"mockMasterKeyEncryptedUserKey",
);
beforeEach(() => {
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
keyService.userPrivateKey$.mockReturnValue(of(userPrivateKey));
const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(mockDecryptedGrantorUserKey);
mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
keyService.makeMasterKey.mockResolvedValueOnce(mockMasterKey);
keyService.hashMasterKey.mockResolvedValueOnce(mockMasterKeyHash);
keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce([
mockGrantorUserKey,
mockMasterKeyEncryptedUserKey,
]);
});
it("posts a new password when decryption succeeds", async () => {
// Arrange
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
keyEncrypted: "EncryptedKey",
kdf: KdfType.PBKDF2_SHA256,
kdfIterations: 500,
} as EmergencyAccessTakeoverResponse);
const mockDecryptedGrantorUserKey = new Uint8Array(64);
keyService.getPrivateKey.mockResolvedValue(new Uint8Array(64));
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(
new SymmetricCryptoKey(mockDecryptedGrantorUserKey),
);
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey;
keyService.makeMasterKey.mockResolvedValueOnce(mockMasterKey);
const mockMasterKeyHash = "mockMasterKeyHash";
keyService.hashMasterKey.mockResolvedValueOnce(mockMasterKeyHash);
// must mock [UserKey, EncString] return from keyService.encryptUserKeyWithMasterKey
// where UserKey is the decrypted grantor user key
const mockMasterKeyEncryptedUserKey = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"mockMasterKeyEncryptedUserKey",
);
const mockUserKey = new SymmetricCryptoKey(mockDecryptedGrantorUserKey) as UserKey;
keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce([
mockUserKey,
mockMasterKeyEncryptedUserKey,
]);
const expectedKdfConfig = new PBKDF2KdfConfig(takeoverResponse.kdfIterations);
const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest();
expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash;
expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString;
// Act
await emergencyAccessService.takeover(mockId, mockEmail, mockName);
await emergencyAccessService.takeover(
params.id,
params.masterPassword,
params.email,
params.activeUserId,
);
// Assert
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(takeoverResponse.keyEncrypted),
userPrivateKey,
);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
params.masterPassword,
params.email,
expectedKdfConfig,
);
expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockGrantorUserKey,
);
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
mockId,
params.id,
expectedEmergencyAccessPasswordRequest,
);
});
it("should not post a new password if decryption fails", async () => {
encryptService.rsaDecrypt.mockResolvedValueOnce(null);
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
it("uses argon2 KDF if takeover response is argon2", async () => {
const argon2TakeoverResponse = {
keyEncrypted: "EncryptedKey",
kdf: KdfType.PBKDF2_SHA256,
kdfIterations: 500,
} as EmergencyAccessTakeoverResponse);
keyService.getPrivateKey.mockResolvedValue(new Uint8Array(64));
kdf: KdfType.Argon2id,
kdfIterations: 3,
kdfMemory: 64,
kdfParallelism: 4,
} as EmergencyAccessTakeoverResponse;
emergencyAccessApiService.postEmergencyAccessTakeover.mockReset();
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(
argon2TakeoverResponse,
);
const expectedKdfConfig = new Argon2KdfConfig(
argon2TakeoverResponse.kdfIterations,
argon2TakeoverResponse.kdfMemory,
argon2TakeoverResponse.kdfParallelism,
);
const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest();
expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash;
expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString;
await emergencyAccessService.takeover(
params.id,
params.masterPassword,
params.email,
params.activeUserId,
);
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(argon2TakeoverResponse.keyEncrypted),
userPrivateKey,
);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
params.masterPassword,
params.email,
expectedKdfConfig,
);
expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockGrantorUserKey,
);
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
params.id,
expectedEmergencyAccessPasswordRequest,
);
});
it("throws an error if masterKeyEncryptedUserKey is not found", async () => {
keyService.encryptUserKeyWithMasterKey.mockReset();
keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce(null);
const expectedKdfConfig = new PBKDF2KdfConfig(takeoverResponse.kdfIterations);
await expect(
emergencyAccessService.takeover(mockId, mockEmail, mockName),
).rejects.toThrowError("Failed to decrypt grantor key");
emergencyAccessService.takeover(
params.id,
params.masterPassword,
params.email,
params.activeUserId,
),
).rejects.toThrow("masterKeyEncryptedUserKey not found");
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(takeoverResponse.keyEncrypted),
userPrivateKey,
);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
params.masterPassword,
params.email,
expectedKdfConfig,
);
expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockGrantorUserKey,
);
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
});
it("should not post a new password if decryption fails", async () => {
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
encryptService.decapsulateKeyUnsigned.mockReset();
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(null);
await expect(
emergencyAccessService.takeover(
params.id,
params.masterPassword,
params.email,
params.activeUserId,
),
).rejects.toThrow("Failed to decrypt grantor key");
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(takeoverResponse.keyEncrypted),
userPrivateKey,
);
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
});
it("should not post a new password if decryption throws", async () => {
encryptService.decapsulateKeyUnsigned.mockReset();
encryptService.decapsulateKeyUnsigned.mockImplementationOnce(() => {
throw new Error("Failed to unwrap grantor key");
});
await expect(
emergencyAccessService.takeover(
params.id,
params.masterPassword,
params.email,
params.activeUserId,
),
).rejects.toThrowError("Failed to unwrap grantor key");
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(takeoverResponse.keyEncrypted),
userPrivateKey,
);
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
});
it("should throw an error if the users private key cannot be retrieved", async () => {
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
keyEncrypted: "EncryptedKey",
kdf: KdfType.PBKDF2_SHA256,
kdfIterations: 500,
} as EmergencyAccessTakeoverResponse);
keyService.getPrivateKey.mockResolvedValue(null);
keyService.userPrivateKey$.mockReturnValue(of(null));
await expect(emergencyAccessService.takeover(mockId, mockEmail, mockName)).rejects.toThrow(
"user does not have a private key",
);
await expect(
emergencyAccessService.takeover(
params.id,
params.masterPassword,
params.email,
params.activeUserId,
),
).rejects.toThrow("user does not have a private key");
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
expect(encryptService.decapsulateKeyUnsigned).not.toHaveBeenCalled();
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
});
});

View File

@@ -1,16 +1,14 @@
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
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 { UserId } from "@bitwarden/common/types/guid";
@@ -59,10 +57,8 @@ export class EmergencyAccessService
private apiService: ApiService,
private keyService: KeyService,
private encryptService: EncryptService,
private bulkEncryptService: BulkEncryptService,
private cipherService: CipherService,
private logService: LogService,
private configService: ConfigService,
) {}
/**
@@ -242,11 +238,14 @@ export class EmergencyAccessService
* Gets the grantor ciphers for an emergency access in view mode.
* Intended for grantee.
* @param id emergency access id
* @param activeUserId the user id of the active user
*/
async getViewOnlyCiphers(id: string): Promise<CipherView[]> {
async getViewOnlyCiphers(id: string, activeUserId: UserId): Promise<CipherView[]> {
const response = await this.emergencyAccessApiService.postEmergencyAccessView(id);
const activeUserPrivateKey = await this.keyService.getPrivateKey();
const activeUserPrivateKey = await firstValueFrom(
this.keyService.userPrivateKey$(activeUserId),
);
if (activeUserPrivateKey == null) {
throw new Error("Active user does not have a private key, cannot get view only ciphers.");
@@ -258,17 +257,8 @@ export class EmergencyAccessService
)) as UserKey;
let ciphers: CipherView[] = [];
if (await this.configService.getFeatureFlag(FeatureFlag.PM4154_BulkEncryptionService)) {
ciphers = await this.bulkEncryptService.decryptItems(
response.ciphers.map((c) => new Cipher(c)),
grantorUserKey,
);
} else {
ciphers = await this.encryptService.decryptItems(
response.ciphers.map((c) => new Cipher(c)),
grantorUserKey,
);
}
const ciphersEncrypted = response.ciphers.map((c) => new Cipher(c));
ciphers = await Promise.all(ciphersEncrypted.map(async (c) => c.decrypt(grantorUserKey)));
return ciphers.sort(this.cipherService.getLocaleSortingFunction());
}
@@ -278,11 +268,14 @@ export class EmergencyAccessService
* @param id emergency access id
* @param masterPassword new master password
* @param email email address of grantee (must be consistent or login will fail)
* @param activeUserId the user id of the active user
*/
async takeover(id: string, masterPassword: string, email: string) {
async takeover(id: string, masterPassword: string, email: string, activeUserId: UserId) {
const takeoverResponse = await this.emergencyAccessApiService.postEmergencyAccessTakeover(id);
const activeUserPrivateKey = await this.keyService.getPrivateKey();
const activeUserPrivateKey = await firstValueFrom(
this.keyService.userPrivateKey$(activeUserId),
);
if (activeUserPrivateKey == null) {
throw new Error("Active user does not have a private key, cannot complete a takeover.");
@@ -326,9 +319,7 @@ export class EmergencyAccessService
request.newMasterPasswordHash = masterKeyHash;
request.key = encKey[1].encryptedString;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
await this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
}
private async getEmergencyAccessData(): Promise<EmergencyAccessGranteeDetailsResponse[]> {

View File

@@ -1,130 +0,0 @@
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate autocomplete="off">
<div class="row justify-content-md-center mt-5">
<div class="col-5">
<p class="lead text-center mb-4">{{ "setMasterPassword" | i18n }}</p>
<div class="card d-block">
<div class="card-body text-center" *ngIf="syncLoading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
{{ "loading" | i18n }}
</div>
<div class="card-body" *ngIf="!syncLoading">
<p
*ngIf="
forceSetPasswordReason ==
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission;
else defaultCardDesc
"
>
{{ "orgPermissionsUpdatedMustSetPassword" | i18n }}
</p>
<ng-template #defaultCardDesc>
<p>{{ "orgRequiresYouToSetPassword" | i18n }}</p>
</ng-template>
<app-callout
type="warning"
title="{{ 'resetPasswordPolicyAutoEnroll' | i18n }}"
*ngIf="resetPasswordAutoEnroll"
>
{{ "resetPasswordAutoEnrollInviteWarning" | i18n }}
</app-callout>
<div class="form-group">
<auth-password-callout [policy]="enforcedPolicyOptions" *ngIf="enforcedPolicyOptions">
</auth-password-callout>
<label for="masterPassword">{{ "masterPass" | i18n }}</label>
<div class="d-flex">
<div class="w-100">
<input
id="masterPassword"
type="{{ showPassword ? 'text' : 'password' }}"
name="MasterPasswordHash"
class="text-monospace form-control mb-1"
[(ngModel)]="masterPassword"
required
appInputVerbatim
/>
<app-password-strength
[password]="masterPassword"
[email]="email"
[showText]="true"
(passwordStrengthResult)="getStrengthResult($event)"
>
</app-password-strength>
</div>
<div>
<button
type="button"
class="ml-1 btn btn-link"
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
(click)="togglePassword(false)"
>
<i
class="bwi bwi-lg"
aria-hidden="true"
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
></i>
</button>
<div class="progress-bar invisible"></div>
</div>
</div>
<small class="form-text text-muted">{{ "masterPassDesc" | i18n }}</small>
</div>
<div class="form-group">
<label for="masterPasswordRetype">{{ "reTypeMasterPass" | i18n }}</label>
<div class="d-flex">
<input
id="masterPasswordRetype"
type="{{ showPassword ? 'text' : 'password' }}"
name="MasterPasswordRetype"
class="text-monospace form-control"
[(ngModel)]="masterPasswordRetype"
required
appInputVerbatim
/>
<button
type="button"
class="ml-1 btn btn-link"
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
(click)="togglePassword(true)"
>
<i
class="bwi bwi-lg"
aria-hidden="true"
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
></i>
</button>
</div>
</div>
<div class="form-group">
<label for="hint">{{ "masterPassHint" | i18n }}</label>
<input id="hint" class="form-control" type="text" name="Hint" [(ngModel)]="hint" />
<small class="form-text text-muted">{{ "masterPassHintDesc" | i18n }}</small>
</div>
<hr />
<div class="d-flex">
<button
type="submit"
class="btn btn-primary btn-block btn-submit"
[disabled]="form.loading"
>
<i
class="bwi bwi-spinner bwi-spin"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span>{{ "submit" | i18n }}</span>
</button>
<button
type="button"
class="btn btn-outline-secondary btn-block ml-2 mt-0"
(click)="logOut()"
>
{{ "logOut" | i18n }}
</button>
</div>
</div>
</div>
</div>
</div>
</form>

View File

@@ -1,30 +0,0 @@
import { Component, inject } from "@angular/core";
import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component";
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";
@Component({
selector: "app-set-password",
templateUrl: "set-password.component.html",
standalone: false,
})
export class SetPasswordComponent extends BaseSetPasswordComponent {
routerService = inject(RouterService);
organizationInviteService = inject(OrganizationInviteService);
protected override async onSetPasswordSuccess(
masterKey: MasterKey,
userKey: [UserKey, EncString],
keyPair: [string, EncString],
): Promise<void> {
await super.onSetPasswordSuccess(masterKey, userKey, keyPair);
// 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.organizationInviteService.clearOrganizationInvitation();
}
}

View File

@@ -89,7 +89,7 @@ describe("ChangeEmailComponent", () => {
});
keyService.getOrDeriveMasterKey
.calledWith("password", "UserId")
.calledWith("password", "UserId" as UserId)
.mockResolvedValue("getOrDeriveMasterKey" as any);
keyService.hashMasterKey
.calledWith("password", "getOrDeriveMasterKey" as any)

View File

@@ -1,129 +0,0 @@
<div class="tabbed-header">
<h1>{{ "changeMasterPassword" | i18n }}</h1>
</div>
<bit-callout type="warning">{{ "loggedOutWarning" | i18n }}</bit-callout>
<auth-password-callout [policy]="enforcedPolicyOptions" *ngIf="enforcedPolicyOptions">
</auth-password-callout>
<form
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
autocomplete="off"
class="tw-mb-14"
>
<div class="row">
<div class="col-6">
<div class="form-group">
<label for="currentMasterPassword">{{ "currentMasterPass" | i18n }}</label>
<input
id="currentMasterPassword"
type="password"
name="MasterPasswordHash"
class="form-control"
[(ngModel)]="currentMasterPassword"
required
appInputVerbatim
/>
</div>
</div>
</div>
<div class="row">
<div class="col-6">
<div class="form-group">
<label for="newMasterPassword">{{ "newMasterPass" | i18n }}</label>
<input
id="newMasterPassword"
type="password"
name="NewMasterPasswordHash"
class="form-control mb-1"
[(ngModel)]="masterPassword"
required
appInputVerbatim
autocomplete="new-password"
/>
<bit-hint>
<span class="tw-font-semibold">{{ "important" | i18n }}</span>
{{ "masterPassImportant" | i18n }} {{ characterMinimumMessage }}
</bit-hint>
<app-password-strength
[password]="masterPassword"
[email]="email"
[showText]="true"
(passwordStrengthResult)="getStrengthResult($event)"
>
</app-password-strength>
</div>
</div>
<div class="col-6">
<div class="form-group">
<label for="masterPasswordRetype">{{ "confirmNewMasterPass" | i18n }}</label>
<input
id="masterPasswordRetype"
type="password"
name="MasterPasswordRetype"
class="form-control"
[(ngModel)]="masterPasswordRetype"
required
appInputVerbatim
autocomplete="new-password"
/>
</div>
</div>
</div>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="checkForBreaches"
name="checkForBreaches"
[(ngModel)]="checkForBreaches"
/>
<label class="form-check-label" for="checkForBreaches">
{{ "checkForBreaches" | i18n }}
</label>
</div>
</div>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="rotateUserKey"
name="RotateUserKey"
[(ngModel)]="rotateUserKey"
(change)="rotateUserKeyClicked()"
/>
<label class="form-check-label" for="rotateUserKey">
{{ "rotateAccountEncKey" | i18n }}
</label>
<a
href="https://bitwarden.com/help/account-encryption-key/#rotate-your-encryption-key"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'impactOfRotatingYourEncryptionKey' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</div>
</div>
<div class="form-group">
<label for="masterPasswordHint">{{ "newMasterPassHint" | i18n }}</label>
<input
id="masterPasswordHint"
class="form-control"
maxlength="50"
type="text"
name="MasterPasswordHint"
[(ngModel)]="masterPasswordHint"
/>
</div>
<button type="submit" buttonType="primary" bitButton [loading]="loading">
{{ "changeMasterPassword" | i18n }}
</button>
</form>
<app-webauthn-login-settings></app-webauthn-login-settings>

View File

@@ -1,258 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "@bitwarden/angular/auth/components/change-password.component";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { getUserId } from "@bitwarden/common/auth/services/account.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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { UserKeyRotationService } from "../../key-management/key-rotation/user-key-rotation.service";
/**
* @deprecated use the auth `PasswordSettingsComponent` instead
*/
@Component({
selector: "app-change-password",
templateUrl: "change-password.component.html",
standalone: false,
})
export class ChangePasswordComponent
extends BaseChangePasswordComponent
implements OnInit, OnDestroy
{
loading = false;
rotateUserKey = false;
currentMasterPassword: string;
masterPasswordHint: string;
checkForBreaches = true;
characterMinimumMessage = "";
constructor(
private auditService: AuditService,
private cipherService: CipherService,
private keyRotationService: UserKeyRotationService,
private masterPasswordApiService: MasterPasswordApiService,
private router: Router,
private syncService: SyncService,
private userVerificationService: UserVerificationService,
protected accountService: AccountService,
protected dialogService: DialogService,
protected i18nService: I18nService,
protected kdfConfigService: KdfConfigService,
protected keyService: KeyService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected messagingService: MessagingService,
protected platformUtilsService: PlatformUtilsService,
protected policyService: PolicyService,
protected toastService: ToastService,
) {
super(
accountService,
dialogService,
i18nService,
kdfConfigService,
keyService,
masterPasswordService,
messagingService,
platformUtilsService,
policyService,
toastService,
);
}
async ngOnInit() {
if (!(await this.userVerificationService.hasMasterPassword())) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/settings/security/two-factor"]);
}
await super.ngOnInit();
this.characterMinimumMessage = this.i18nService.t("characterMinimum", this.minimumLength);
}
async rotateUserKeyClicked() {
if (this.rotateUserKey) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const ciphers = await this.cipherService.getAllDecrypted(activeUserId);
let hasOldAttachments = false;
if (ciphers != null) {
for (let i = 0; i < ciphers.length; i++) {
if (ciphers[i].organizationId == null && ciphers[i].hasOldAttachments) {
hasOldAttachments = true;
break;
}
}
}
if (hasOldAttachments) {
const learnMore = await this.dialogService.openSimpleDialog({
title: { key: "warning" },
content: { key: "oldAttachmentsNeedFixDesc" },
acceptButtonText: { key: "learnMore" },
cancelButtonText: { key: "close" },
type: "warning",
});
if (learnMore) {
this.platformUtilsService.launchUri(
"https://bitwarden.com/help/attachments/#add-storage-space",
);
}
this.rotateUserKey = false;
return;
}
const result = await this.dialogService.openSimpleDialog({
title: { key: "rotateEncKeyTitle" },
content:
this.i18nService.t("updateEncryptionKeyWarning") +
" " +
this.i18nService.t("updateEncryptionKeyAccountExportWarning") +
" " +
this.i18nService.t("rotateEncKeyConfirmation"),
type: "warning",
});
if (!result) {
this.rotateUserKey = false;
}
}
}
async submit() {
this.loading = true;
if (this.currentMasterPassword == null || this.currentMasterPassword === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordRequired"),
});
this.loading = false;
return;
}
if (
this.masterPasswordHint != null &&
this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase()
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("hintEqualsPassword"),
});
this.loading = false;
return;
}
this.leakedPassword = false;
if (this.checkForBreaches) {
this.leakedPassword = (await this.auditService.passwordLeaked(this.masterPassword)) > 0;
}
if (!(await this.strongPassword())) {
this.loading = false;
return;
}
try {
if (this.rotateUserKey) {
await this.syncService.fullSync(true);
const user = await firstValueFrom(this.accountService.activeAccount$);
await this.keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
this.currentMasterPassword,
this.masterPassword,
user,
this.masterPasswordHint,
);
} else {
await this.updatePassword(this.masterPassword);
}
} catch (e) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
} finally {
this.loading = false;
}
}
// todo: move this to a service
// https://bitwarden.atlassian.net/browse/PM-17108
private async updatePassword(newMasterPassword: string) {
const currentMasterPassword = this.currentMasterPassword;
const { userId, email } = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => ({ userId: a?.id, email: a?.email }))),
);
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
const currentMasterKey = await this.keyService.makeMasterKey(
currentMasterPassword,
email,
kdfConfig,
);
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
currentMasterKey,
userId,
);
if (decryptedUserKey == null) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("invalidMasterPassword"),
});
return;
}
const newMasterKey = await this.keyService.makeMasterKey(newMasterPassword, email, kdfConfig);
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
decryptedUserKey,
);
const request = new PasswordRequest();
request.masterPasswordHash = await this.keyService.hashMasterKey(
this.currentMasterPassword,
currentMasterKey,
);
request.masterPasswordHint = this.masterPasswordHint;
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
newMasterPassword,
newMasterKey,
);
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
try {
await this.masterPasswordApiService.postPassword(request);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("masterPasswordChanged"),
});
this.messagingService.send("logout");
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
}
}

View File

@@ -8,6 +8,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { DialogConfig, DialogRef, DIALOG_DATA, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../../shared";
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum EmergencyAccessConfirmDialogResult {
@@ -24,9 +26,8 @@ type EmergencyAccessConfirmDialogData = {
publicKey: Uint8Array;
};
@Component({
selector: "emergency-access-confirm",
templateUrl: "emergency-access-confirm.component.html",
standalone: false,
imports: [SharedModule],
})
export class EmergencyAccessConfirmComponent implements OnInit {
loading = true;

View File

@@ -14,6 +14,8 @@ import {
ToastService,
} from "@bitwarden/components";
import { SharedModule } from "../../../shared/shared.module";
import { PremiumBadgeComponent } from "../../../vault/components/premium-badge.component";
import { EmergencyAccessService } from "../../emergency-access";
import { EmergencyAccessType } from "../../emergency-access/enums/emergency-access-type";
@@ -34,9 +36,8 @@ export enum EmergencyAccessAddEditDialogResult {
Deleted = "deleted",
}
@Component({
selector: "emergency-access-add-edit",
templateUrl: "emergency-access-add-edit.component.html",
standalone: false,
imports: [SharedModule, PremiumBadgeComponent],
})
export class EmergencyAccessAddEditComponent implements OnInit {
loading = true;

View File

@@ -10,8 +10,6 @@ import { OrganizationManagementPreferencesService } from "@bitwarden/common/admi
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -20,6 +18,9 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService, ToastService } from "@bitwarden/components";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared/shared.module";
import { PremiumBadgeComponent } from "../../../vault/components/premium-badge.component";
import { EmergencyAccessService } from "../../emergency-access";
import { EmergencyAccessStatusType } from "../../emergency-access/enums/emergency-access-status-type";
import { EmergencyAccessType } from "../../emergency-access/enums/emergency-access-type";
@@ -40,15 +41,10 @@ import {
EmergencyAccessTakeoverDialogComponent,
EmergencyAccessTakeoverDialogResultType,
} from "./takeover/emergency-access-takeover-dialog.component";
import {
EmergencyAccessTakeoverComponent,
EmergencyAccessTakeoverResultType,
} from "./takeover/emergency-access-takeover.component";
@Component({
selector: "emergency-access",
templateUrl: "emergency-access.component.html",
standalone: false,
imports: [SharedModule, HeaderModule, PremiumBadgeComponent],
})
export class EmergencyAccessComponent implements OnInit {
loaded = false;
@@ -75,7 +71,6 @@ export class EmergencyAccessComponent implements OnInit {
private toastService: ToastService,
private apiService: ApiService,
private accountService: AccountService,
private configService: ConfigService,
) {
this.canAccessPremium$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
@@ -292,60 +287,36 @@ export class EmergencyAccessComponent implements OnInit {
}
takeover = async (details: GrantorEmergencyAccess) => {
const changePasswordRefactorFlag = await this.configService.getFeatureFlag(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
if (changePasswordRefactorFlag) {
if (!details || !details.email || !details.id) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("grantorDetailsNotFound"),
});
this.logService.error(
"Grantor details not found when attempting emergency access takeover",
);
return;
}
const grantorName = this.userNamePipe.transform(details);
const dialogRef = EmergencyAccessTakeoverDialogComponent.open(this.dialogService, {
data: {
grantorName,
grantorEmail: details.email,
emergencyAccessId: details.id,
},
if (!details || !details.email || !details.id) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("grantorDetailsNotFound"),
});
const result = await lastValueFrom(dialogRef.closed);
if (result === EmergencyAccessTakeoverDialogResultType.Done) {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("passwordResetFor", grantorName),
});
}
this.logService.error("Grantor details not found when attempting emergency access takeover");
return;
}
const dialogRef = EmergencyAccessTakeoverComponent.open(this.dialogService, {
const grantorName = this.userNamePipe.transform(details);
const dialogRef = EmergencyAccessTakeoverDialogComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(details),
email: details.email,
emergencyAccessId: details.id ?? null,
grantorName,
grantorEmail: details.email,
emergencyAccessId: details.id,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === EmergencyAccessTakeoverResultType.Done) {
if (result === EmergencyAccessTakeoverDialogResultType.Done) {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("passwordResetFor", this.userNamePipe.transform(details)),
title: "",
message: this.i18nService.t("passwordResetFor", grantorName),
});
}
return;
};
private removeGrantee(details: GranteeEmergencyAccess) {

View File

@@ -49,7 +49,6 @@ export type EmergencyAccessTakeoverDialogResultType =
* @link https://bitwarden.com/help/emergency-access/
*/
@Component({
standalone: true,
selector: "auth-emergency-access-takeover-dialog",
templateUrl: "./emergency-access-takeover-dialog.component.html",
imports: [
@@ -116,10 +115,12 @@ export class EmergencyAccessTakeoverDialogComponent implements OnInit {
this.parentSubmittingBehaviorSubject.next(true);
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.emergencyAccessService.takeover(
this.dialogData.emergencyAccessId,
passwordInputResult.newPassword,
this.dialogData.grantorEmail,
activeUserId,
);
} catch (e) {
this.logService.error(e);

View File

@@ -1,54 +0,0 @@
<form [formGroup]="takeoverForm" [bitSubmit]="submit">
<bit-dialog dialogSize="large">
<span bitDialogTitle>
{{ "takeover" | i18n }}
<small class="tw-text-muted" *ngIf="params.name">{{ params.name }}</small>
</span>
<div bitDialogContent>
<bit-callout type="warning">{{ "loggedOutWarning" | i18n }}</bit-callout>
<auth-password-callout [policy]="enforcedPolicyOptions" *ngIf="enforcedPolicyOptions">
</auth-password-callout>
<div class="tw-w-full tw-flex tw-gap-4">
<div class="tw-relative tw-flex-1">
<bit-form-field disableMargin class="tw-mb-2">
<bit-label>{{ "newMasterPass" | i18n }}</bit-label>
<input
bitInput
type="password"
autocomplete="new-password"
formControlName="masterPassword"
/>
<button type="button" bitSuffix bitIconButton bitPasswordInputToggle></button>
</bit-form-field>
<app-password-strength
[password]="takeoverForm.value.masterPassword"
[email]="email"
[showText]="true"
(passwordStrengthResult)="getStrengthResult($event)"
>
</app-password-strength>
</div>
<div class="tw-relative tw-flex-1">
<bit-form-field disableMargin class="tw-mb-2">
<bit-label>{{ "confirmNewMasterPass" | i18n }}</bit-label>
<input
bitInput
type="password"
autocomplete="new-password"
formControlName="masterPasswordRetype"
/>
<button type="button" bitSuffix bitIconButton bitPasswordInputToggle></button>
</bit-form-field>
</div>
</div>
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "save" | i18n }}
</button>
<button bitButton bitFormButton type="button" buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -1,145 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnDestroy, OnInit, Inject, Input } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { switchMap, takeUntil } from "rxjs";
import { ChangePasswordComponent } from "@bitwarden/angular/auth/components/change-password.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
DialogConfig,
DialogRef,
DIALOG_DATA,
DialogService,
ToastService,
} from "@bitwarden/components";
import { KdfType, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { EmergencyAccessService } from "../../../emergency-access";
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum EmergencyAccessTakeoverResultType {
Done = "done",
}
type EmergencyAccessTakeoverDialogData = {
/** display name of the account requesting emergency access takeover */
name: string;
/** email of the account requesting emergency access takeover */
email: string;
/** traces a unique emergency request */
emergencyAccessId: string;
};
@Component({
selector: "emergency-access-takeover",
templateUrl: "emergency-access-takeover.component.html",
standalone: false,
})
export class EmergencyAccessTakeoverComponent
extends ChangePasswordComponent
implements OnInit, OnDestroy
{
@Input() kdf: KdfType;
@Input() kdfIterations: number;
takeoverForm = this.formBuilder.group({
masterPassword: ["", [Validators.required]],
masterPasswordRetype: ["", [Validators.required]],
});
constructor(
@Inject(DIALOG_DATA) protected params: EmergencyAccessTakeoverDialogData,
private formBuilder: FormBuilder,
i18nService: I18nService,
keyService: KeyService,
messagingService: MessagingService,
platformUtilsService: PlatformUtilsService,
policyService: PolicyService,
private emergencyAccessService: EmergencyAccessService,
private logService: LogService,
dialogService: DialogService,
private dialogRef: DialogRef<EmergencyAccessTakeoverResultType>,
kdfConfigService: KdfConfigService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
accountService: AccountService,
protected toastService: ToastService,
) {
super(
accountService,
dialogService,
i18nService,
kdfConfigService,
keyService,
masterPasswordService,
messagingService,
platformUtilsService,
policyService,
toastService,
);
}
async ngOnInit() {
const policies = await this.emergencyAccessService.getGrantorPolicies(
this.params.emergencyAccessId,
);
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId, policies)),
takeUntil(this.destroy$),
)
.subscribe((enforcedPolicyOptions) => (this.enforcedPolicyOptions = enforcedPolicyOptions));
}
ngOnDestroy(): void {
super.ngOnDestroy();
}
submit = async () => {
if (this.takeoverForm.invalid) {
this.takeoverForm.markAllAsTouched();
return;
}
this.masterPassword = this.takeoverForm.get("masterPassword").value;
this.masterPasswordRetype = this.takeoverForm.get("masterPasswordRetype").value;
if (!(await this.strongPassword())) {
return;
}
try {
await this.emergencyAccessService.takeover(
this.params.emergencyAccessId,
this.masterPassword,
this.params.email,
);
} catch (e) {
this.logService.error(e);
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("unexpectedError"),
});
}
this.dialogRef.close(EmergencyAccessTakeoverResultType.Done);
};
/**
* Strongly typed helper to open a EmergencyAccessTakeoverComponent
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
static open = (
dialogService: DialogService,
config: DialogConfig<EmergencyAccessTakeoverDialogData>,
) => {
return dialogService.open<EmergencyAccessTakeoverResultType>(
EmergencyAccessTakeoverComponent,
config,
);
};
}

View File

@@ -2,20 +2,22 @@ import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EmergencyAccessId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { CipherFormConfigService, DefaultCipherFormConfigService } from "@bitwarden/vault";
import { SharedModule } from "../../../../shared/shared.module";
import { EmergencyAccessService } from "../../../emergency-access";
import { EmergencyViewDialogComponent } from "./emergency-view-dialog.component";
@Component({
selector: "emergency-access-view",
templateUrl: "emergency-access-view.component.html",
providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }],
standalone: false,
imports: [SharedModule],
})
export class EmergencyAccessViewComponent implements OnInit {
id: EmergencyAccessId | null = null;
@@ -27,6 +29,7 @@ export class EmergencyAccessViewComponent implements OnInit {
private route: ActivatedRoute,
private emergencyAccessService: EmergencyAccessService,
private dialogService: DialogService,
private accountService: AccountService,
) {}
async ngOnInit() {
@@ -37,7 +40,8 @@ export class EmergencyAccessViewComponent implements OnInit {
}
this.id = qParams.id;
this.ciphers = await this.emergencyAccessService.getViewOnlyCiphers(qParams.id);
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.ciphers = await this.emergencyAccessService.getViewOnlyCiphers(qParams.id, userId);
this.loaded = true;
}

View File

@@ -2,11 +2,14 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -60,6 +63,11 @@ describe("EmergencyViewDialogComponent", () => {
{ provide: AccountService, useValue: accountService },
{ provide: TaskService, useValue: mock<TaskService>() },
{ provide: LogService, useValue: mock<LogService>() },
{
provide: EnvironmentService,
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
},
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
],
})
.overrideComponent(EmergencyViewDialogComponent, {

View File

@@ -2,14 +2,13 @@
// @ts-strict-ignore
import { Component, Inject } from "@angular/core";
import { FormGroup, FormControl, Validators } from "@angular/forms";
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfRequest } from "@bitwarden/common/models/request/kdf.request";
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 { DIALOG_DATA, ToastService } from "@bitwarden/components";
import { KdfConfig, KdfType, KeyService } from "@bitwarden/key-management";
@@ -31,7 +30,6 @@ export class ChangeKdfConfirmationComponent {
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private keyService: KeyService,
private messagingService: MessagingService,
@Inject(DIALOG_DATA) params: { kdf: KdfType; kdfConfig: KdfConfig },
@@ -58,6 +56,10 @@ export class ChangeKdfConfirmationComponent {
};
private async makeKeyAndSaveAsync() {
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount == null) {
throw new Error("No active account found.");
}
const masterPassword = this.form.value.masterPassword;
// Ensure the KDF config is valid.
@@ -70,13 +72,14 @@ export class ChangeKdfConfirmationComponent {
request.kdfMemory = this.kdfConfig.memory;
request.kdfParallelism = this.kdfConfig.parallelism;
}
const masterKey = await this.keyService.getOrDeriveMasterKey(masterPassword);
const masterKey = await this.keyService.getOrDeriveMasterKey(masterPassword, activeAccount.id);
request.masterPasswordHash = await this.keyService.hashMasterKey(masterPassword, masterKey);
const email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
const newMasterKey = await this.keyService.makeMasterKey(masterPassword, email, this.kdfConfig);
const newMasterKey = await this.keyService.makeMasterKey(
masterPassword,
activeAccount.email,
this.kdfConfig,
);
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
masterPassword,
newMasterKey,

View File

@@ -11,7 +11,7 @@
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</button>
<bit-popover [title]="'whatIsADevice' | i18n" #infoPopover>
<p>{{ "aDeviceIs" | i18n }}</p>
<p class="tw-mb-0">{{ "aDeviceIs" | i18n }}</p>
</bit-popover>
<i
*ngIf="asyncActionLoading"

View File

@@ -3,7 +3,7 @@ import { Component, DestroyRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom } from "rxjs";
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval";
import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import {
@@ -325,7 +325,7 @@ export class DeviceManagementOldComponent {
return;
}
const dialogRef = LoginApprovalComponent.open(this.dialogService, {
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: device.devicePendingAuthRequest.id,
});

View File

@@ -2,11 +2,9 @@ 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 { DeviceManagementOldComponent } from "./device-management-old.component";
@@ -21,30 +19,9 @@ const routes: Routes = [
data: { titleId: "security" },
children: [
{ path: "", pathMatch: "full", redirectTo: "password" },
{
path: "change-password",
component: ChangePasswordComponent,
canActivate: [
canAccessFeature(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
false,
"/settings/security/password",
false,
),
],
data: { titleId: "masterPassword" },
},
{
path: "password",
component: PasswordSettingsComponent,
canActivate: [
canAccessFeature(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
true,
"/settings/security/change-password",
false,
),
],
data: { titleId: "masterPassword" },
},
{

View File

@@ -1,8 +1,6 @@
import { Component, OnInit } from "@angular/core";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
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";
@@ -13,21 +11,11 @@ import { SharedModule } from "../../../shared";
})
export class SecurityComponent implements OnInit {
showChangePassword = true;
changePasswordRoute = "change-password";
changePasswordRoute = "password";
constructor(
private userVerificationService: UserVerificationService,
private configService: ConfigService,
) {}
constructor(private userVerificationService: UserVerificationService) {}
async ngOnInit() {
this.showChangePassword = await this.userVerificationService.hasMasterPassword();
const changePasswordRefreshFlag = await this.configService.getFeatureFlag(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
if (changePasswordRefreshFlag) {
this.changePasswordRoute = "password";
}
}
}

View File

@@ -6,7 +6,6 @@ import { UserKeyRotationModule } from "../../key-management/key-rotation/user-ke
import { SharedModule } from "../../shared";
import { EmergencyAccessModule } from "../emergency-access";
import { ChangePasswordComponent } from "./change-password.component";
import { WebauthnLoginSettingsModule } from "./webauthn-login-settings";
@NgModule({
@@ -17,8 +16,8 @@ import { WebauthnLoginSettingsModule } from "./webauthn-login-settings";
PasswordCalloutComponent,
UserKeyRotationModule,
],
declarations: [ChangePasswordComponent],
declarations: [],
providers: [],
exports: [ChangePasswordComponent],
exports: [],
})
export class AuthSettingsModule {}

View File

@@ -1,90 +0,0 @@
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate autocomplete="off">
<div class="row justify-content-md-center mt-5">
<div class="col-4">
<p class="lead text-center mb-4">{{ "updateMasterPassword" | i18n }}</p>
<div class="card d-block">
<div class="card-body">
<app-callout type="warning">{{ "masterPasswordInvalidWarning" | i18n }} </app-callout>
<auth-password-callout
[policy]="enforcedPolicyOptions"
*ngIf="enforcedPolicyOptions"
></auth-password-callout>
<form
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
autocomplete="off"
>
<div class="row">
<div class="col-6">
<div class="form-group">
<label for="currentMasterPassword">{{ "currentMasterPass" | i18n }}</label>
<input
id="currentMasterPassword"
type="password"
name="MasterPasswordHash"
class="form-control"
[(ngModel)]="currentMasterPassword"
required
appInputVerbatim
/>
</div>
</div>
</div>
<div class="row">
<div class="col-6">
<div class="form-group">
<label for="newMasterPassword">{{ "newMasterPass" | i18n }}</label>
<input
id="newMasterPassword"
type="password"
name="NewMasterPasswordHash"
class="form-control mb-1"
[(ngModel)]="masterPassword"
required
appInputVerbatim
autocomplete="new-password"
/>
<app-password-strength
[password]="masterPassword"
[email]="email"
[showText]="true"
(passwordStrengthResult)="getStrengthResult($event)"
></app-password-strength>
</div>
</div>
<div class="col-6">
<div class="form-group">
<label for="masterPasswordRetype">{{ "confirmNewMasterPass" | i18n }}</label>
<input
id="masterPasswordRetype"
type="password"
name="MasterPasswordRetype"
class="form-control"
[(ngModel)]="masterPasswordRetype"
required
appInputVerbatim
autocomplete="new-password"
/>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i
class="fa fa-spinner fa-spin"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span>{{ "changeMasterPassword" | i18n }}</span>
</button>
<button (click)="cancel()" type="button" class="btn btn-outline-secondary">
<span>{{ "cancel" | i18n }}</span>
</button>
</form>
</div>
</div>
</div>
</div>
</form>

View File

@@ -1,24 +0,0 @@
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";
@Component({
selector: "app-update-password",
templateUrl: "update-password.component.html",
standalone: false,
})
export class UpdatePasswordComponent extends BaseUpdatePasswordComponent {
private routerService = inject(RouterService);
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.organizationInviteService.clearOrganizationInvitation();
await super.cancel();
}
}

View File

@@ -1,96 +0,0 @@
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate autocomplete="off">
<div class="tw-mt-12 tw-flex tw-justify-center">
<div class="tw-w-1/3">
<h1 bitTypography="h1" class="tw-mb-4 tw-text-center">{{ "updateMasterPassword" | i18n }}</h1>
<div
class="tw-block tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-8"
>
<app-callout type="warning">{{ masterPasswordWarningText }} </app-callout>
<auth-password-callout [policy]="enforcedPolicyOptions" *ngIf="enforcedPolicyOptions">
</auth-password-callout>
<bit-form-field *ngIf="requireCurrentPassword">
<bit-label>{{ "currentMasterPass" | i18n }}</bit-label>
<input
bitInput
type="password"
appInputVerbatim
required
[(ngModel)]="verification.secret"
name="currentMasterPassword"
id="currentMasterPassword"
[appAutofocus]="requireCurrentPassword"
/>
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field>
<div class="tw-mb-4">
<bit-form-field class="!tw-mb-1">
<bit-label>{{ "newMasterPass" | i18n }}</bit-label>
<input
bitInput
type="password"
appInputVerbatim
required
[(ngModel)]="masterPassword"
name="masterPassword"
id="masterPassword"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
</bit-form-field>
<app-password-strength
[password]="masterPassword"
[email]="email"
[showText]="true"
(passwordStrengthResult)="getStrengthResult($event)"
>
</app-password-strength>
</div>
<bit-form-field>
<bit-label>{{ "confirmNewMasterPass" | i18n }}</bit-label>
<input
bitInput
type="password"
appInputVerbatim
required
[(ngModel)]="masterPasswordRetype"
name="masterPasswordRetype"
id="masterPasswordRetype"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "masterPassHint" | i18n }}</bit-label>
<input bitInput type="text" [(ngModel)]="hint" name="hint" id="hint" />
<bit-hint>{{ "masterPassHintDesc" | i18n }}</bit-hint>
</bit-form-field>
<hr />
<div class="tw-flex tw-space-x-2">
<button
type="submit"
bitButton
[block]="true"
buttonType="primary"
[loading]="form.loading"
[disabled]="form.loading"
>
{{ "submit" | i18n }}
</button>
<button type="button" bitButton [block]="true" buttonType="secondary" (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</div>
</div>
</div>
</form>

View File

@@ -1,10 +0,0 @@
import { Component } from "@angular/core";
import { UpdateTempPasswordComponent as BaseUpdateTempPasswordComponent } from "@bitwarden/angular/auth/components/update-temp-password.component";
@Component({
selector: "app-update-temp-password",
templateUrl: "update-temp-password.component.html",
standalone: false,
})
export class UpdateTempPasswordComponent extends BaseUpdateTempPasswordComponent {}

View File

@@ -304,6 +304,7 @@ export class TrialBillingStepComponent implements OnInit, OnDestroy {
this.fetchingTaxAmount = true;
if (!this.taxInfoComponent.validate()) {
this.fetchingTaxAmount = false;
return 0;
}
@@ -326,7 +327,7 @@ export class TrialBillingStepComponent implements OnInit, OnDestroy {
const response = await this.taxService.previewTaxAmountForOrganizationTrial(request);
this.fetchingTaxAmount = false;
return response.taxAmount;
return response;
};
get price() {

View File

@@ -11,24 +11,6 @@
}}</span>
<!-- Discount Badge -->
<div class="tw-flex tw-items-center tw-gap-2">
<span
class="tw-mr-1"
[hidden]="isSubscriptionCanceled"
*ngIf="
this.discountPercentageFromSub > 0
? discountPercentageFromSub
: this.discountPercentage && selectedInterval === planIntervals.Annually
"
bitBadge
variant="success"
>{{
"upgradeDiscount"
| i18n
: (selectedInterval === planIntervals.Annually && discountPercentageFromSub == 0
? this.discountPercentage
: this.discountPercentageFromSub)
}}</span
>
<!-- Plan Interval Toggle -->
<div class="tw-inline-block" *ngIf="!isSubscriptionCanceled">
<bit-toggle-group
@@ -929,7 +911,7 @@
</div>
<!-- discountPercentage to PM Only -->
<div
*ngIf="totalOpened && discountPercentage && !organization.useSecretsManager"
*ngIf="totalOpened && !organization.useSecretsManager"
class="tw-flex tw-flex-wrap tw-gap-4"
>
<bit-hint class="tw-w-1/2">

View File

@@ -54,6 +54,7 @@ import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.res
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import {
DIALOG_DATA,
@@ -149,7 +150,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
@Output() onCanceled = new EventEmitter<void>();
@Output() onTrialBillingSuccess = new EventEmitter();
protected discountPercentage: number = 20;
protected discountPercentageFromSub: number;
protected loading = true;
protected planCards: PlanCard[];
@@ -892,7 +892,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
// Backfill pub/priv key if necessary
if (!this.organization.hasPublicAndPrivateKeys) {
const orgShareKey = await this.keyService.getOrgKey(this.organizationId);
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const orgShareKey = await firstValueFrom(
this.keyService
.orgKeys$(userId)
.pipe(map((orgKeys) => orgKeys?.[this.organizationId as OrganizationId] ?? null)),
);
const orgKeys = await this.keyService.makeKeyPair(orgShareKey);
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
}

View File

@@ -54,6 +54,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "@bitwarden/components";
@@ -756,7 +757,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
// Backfill pub/priv key if necessary
if (!this.organization.hasPublicAndPrivateKeys) {
const orgShareKey = await this.keyService.getOrgKey(this.organizationId);
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const orgShareKey = await firstValueFrom(
this.keyService
.orgKeys$(userId)
.pipe(map((orgKeys) => orgKeys?.[this.organizationId as OrganizationId] ?? null)),
);
const orgKeys = await this.keyService.makeKeyPair(orgShareKey);
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
}

View File

@@ -36,6 +36,10 @@ import {
AdjustPaymentDialogComponent,
AdjustPaymentDialogResultType,
} from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component";
import {
TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE,
TrialPaymentDialogComponent,
} from "../../shared/trial-payment-dialog/trial-payment-dialog.component";
import { FreeTrial } from "../../types/free-trial";
@Component({
@@ -212,15 +216,15 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
};
changePayment = async () => {
const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, {
const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, {
data: {
initialPaymentMethod: this.paymentSource?.type,
organizationId: this.organizationId,
productTier: this.organization?.productTierType,
subscription: this.organizationSubscriptionResponse,
productTierType: this.organization?.productTierType,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustPaymentDialogResultType.Submitted) {
if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) {
this.location.replaceState(this.location.path(), "", {});
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
await this.syncService.fullSync(true);

View File

@@ -1,5 +1,5 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject, ViewChild } from "@angular/core";
import { Component, Inject } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components";
@@ -7,19 +7,17 @@ import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden
import { SharedModule } from "../../../shared";
import { BillingClient } from "../../services";
import { BillableEntity } from "../../types";
import { MaskedPaymentMethod } from "../types";
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
import {
SubmitPaymentMethodDialogComponent,
SubmitPaymentMethodDialogResult,
} from "./submit-payment-method-dialog.component";
type DialogParams = {
owner: BillableEntity;
};
type DialogResult =
| { type: "cancelled" }
| { type: "error" }
| { type: "success"; paymentMethod: MaskedPaymentMethod };
@Component({
template: `
<form [formGroup]="formGroup" [bitSubmit]="submit">
@@ -28,7 +26,11 @@ type DialogResult =
{{ "changePaymentMethod" | i18n }}
</span>
<div bitDialogContent>
<app-enter-payment-method [group]="formGroup" [includeBillingAddress]="true">
<app-enter-payment-method
[group]="formGroup"
[showBankAccount]="dialogParams.owner.type !== 'account'"
[includeBillingAddress]="true"
>
</app-enter-payment-method>
</div>
<ng-container bitDialogFooter>
@@ -51,63 +53,23 @@ type DialogResult =
imports: [EnterPaymentMethodComponent, SharedModule],
providers: [BillingClient],
})
export class ChangePaymentMethodDialogComponent {
@ViewChild(EnterPaymentMethodComponent)
private enterPaymentMethodComponent!: EnterPaymentMethodComponent;
protected formGroup = EnterPaymentMethodComponent.getFormGroup();
export class ChangePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent {
protected override owner: BillableEntity;
constructor(
private billingClient: BillingClient,
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;
}
}
};
dialogRef: DialogRef<SubmitPaymentMethodDialogResult>,
i18nService: I18nService,
toastService: ToastService,
) {
super(billingClient, dialogRef, i18nService, toastService);
this.owner = this.dialogParams.owner;
}
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) =>
dialogService.open<DialogResult>(ChangePaymentMethodDialogComponent, dialogConfig);
dialogService.open<SubmitPaymentMethodDialogResult>(
ChangePaymentMethodDialogComponent,
dialogConfig,
);
}

View File

@@ -1,6 +1,6 @@
import { Component, Input, OnInit } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { BehaviorSubject, startWith, Subject, takeUntil } from "rxjs";
import { map, Observable, of, startWith, Subject, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -48,7 +48,7 @@ type PaymentMethodFormGroup = FormGroup<{
{{ "creditCard" | i18n }}
</bit-label>
</bit-radio-button>
@if (showBankAccount) {
@if (showBankAccount$ | async) {
<bit-radio-button id="bank-payment-method" [value]="'bankAccount'">
<bit-label>
<i class="bwi bwi-fw bwi-billing" aria-hidden="true"></i>
@@ -108,7 +108,7 @@ type PaymentMethodFormGroup = FormGroup<{
<i class="bwi bwi-question-circle tw-text-lg" aria-hidden="true"></i>
</button>
<bit-popover [title]="'cardSecurityCode' | i18n" #cardSecurityCodePopover>
<p>{{ "cardSecurityCodeDescription" | i18n }}</p>
<p class="tw-mb-0">{{ "cardSecurityCodeDescription" | i18n }}</p>
</bit-popover>
</app-payment-label>
<div id="stripe-card-cvc" class="tw-stripe-form-control"></div>
@@ -226,20 +226,12 @@ type PaymentMethodFormGroup = FormGroup<{
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;
@Input() private showBankAccount = true;
@Input() showPayPal = true;
@Input() showAccountCredit = false;
@Input() includeBillingAddress = false;
protected showBankAccount$!: Observable<boolean>;
protected selectableCountries = selectableCountries;
private destroy$ = new Subject<void>();
@@ -267,7 +259,16 @@ export class EnterPaymentMethodComponent implements OnInit {
}
if (!this.includeBillingAddress) {
this.showBankAccount$ = of(this.showBankAccount);
this.group.controls.billingAddress.disable();
} else {
this.group.controls.billingAddress.patchValue({
country: "US",
});
this.showBankAccount$ = this.group.controls.billingAddress.controls.country.valueChanges.pipe(
startWith(this.group.controls.billingAddress.controls.country.value),
map((country) => this.showBankAccount && country === "US"),
);
}
this.group.controls.type.valueChanges

View File

@@ -6,4 +6,6 @@ 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 "./require-payment-method-dialog.component";
export * from "./submit-payment-method-dialog.component";
export * from "./verify-bank-account.component";

View File

@@ -0,0 +1,77 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
CalloutTypes,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { BillingClient } from "../../services";
import { BillableEntity } from "../../types";
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
import {
SubmitPaymentMethodDialogComponent,
SubmitPaymentMethodDialogResult,
} from "./submit-payment-method-dialog.component";
type DialogParams = {
owner: BillableEntity;
callout: {
type: CalloutTypes;
title: string;
message: string;
};
};
@Component({
template: `
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
{{ "addPaymentMethod" | i18n }}
</span>
<div bitDialogContent>
<bit-callout [type]="dialogParams.callout.type" [title]="dialogParams.callout.title">
{{ dialogParams.callout.message }}
</bit-callout>
<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>
</ng-container>
</bit-dialog>
</form>
`,
standalone: true,
imports: [EnterPaymentMethodComponent, SharedModule],
providers: [BillingClient],
})
export class RequirePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent {
protected override owner: BillableEntity;
constructor(
billingClient: BillingClient,
@Inject(DIALOG_DATA) protected dialogParams: DialogParams,
dialogRef: DialogRef<SubmitPaymentMethodDialogResult>,
i18nService: I18nService,
toastService: ToastService,
) {
super(billingClient, dialogRef, i18nService, toastService);
this.owner = this.dialogParams.owner;
}
static open = (dialogService: DialogService, dialogConfig: DialogConfig<DialogParams>) =>
dialogService.open<SubmitPaymentMethodDialogResult>(RequirePaymentMethodDialogComponent, {
...dialogConfig,
disableClose: true,
});
}

View File

@@ -0,0 +1,75 @@
import { Component, ViewChild } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogRef, ToastService } from "@bitwarden/components";
import { BillingClient } from "../../services";
import { BillableEntity } from "../../types";
import { MaskedPaymentMethod } from "../types";
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
export type SubmitPaymentMethodDialogResult =
| { type: "cancelled" }
| { type: "error" }
| { type: "success"; paymentMethod: MaskedPaymentMethod };
@Component({ template: "" })
export abstract class SubmitPaymentMethodDialogComponent {
@ViewChild(EnterPaymentMethodComponent)
private enterPaymentMethodComponent!: EnterPaymentMethodComponent;
protected formGroup = EnterPaymentMethodComponent.getFormGroup();
protected abstract owner: BillableEntity;
protected constructor(
protected billingClient: BillingClient,
protected dialogRef: DialogRef<SubmitPaymentMethodDialogResult>,
protected i18nService: I18nService,
protected 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.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;
}
}
};
}

View File

@@ -0,0 +1,59 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
@Injectable({ providedIn: "root" })
export class PlanCardService {
constructor(private apiService: ApiService) {}
async getCadenceCards(
currentPlan: PlanResponse,
subscription: OrganizationSubscriptionResponse,
isSecretsManagerTrial: boolean,
) {
const plans = await this.apiService.getPlans();
const filteredPlans = plans.data.filter((plan) => !!plan.PasswordManager);
const result =
filteredPlans?.filter(
(plan) =>
plan.productTier === currentPlan.productTier && !plan.disabled && !plan.legacyYear,
) || [];
const planCards = result.map((plan) => {
let costPerMember = 0;
if (plan.PasswordManager.basePrice) {
costPerMember = plan.isAnnual
? plan.PasswordManager.basePrice / 12
: plan.PasswordManager.basePrice;
} else if (!plan.PasswordManager.basePrice && plan.PasswordManager.hasAdditionalSeatsOption) {
const secretsManagerCost = subscription.useSecretsManager
? plan.SecretsManager.seatPrice
: 0;
const passwordManagerCost = isSecretsManagerTrial ? 0 : plan.PasswordManager.seatPrice;
costPerMember = (secretsManagerCost + passwordManagerCost) / (plan.isAnnual ? 12 : 1);
}
const percentOff = subscription.customerDiscount?.percentOff ?? 0;
const discount =
(percentOff === 0 && plan.isAnnual) || isSecretsManagerTrial ? 20 : percentOff;
return {
title: plan.isAnnual ? "Annually" : "Monthly",
costPerMember,
discount,
isDisabled: false,
isSelected: plan.isAnnual,
isAnnual: plan.isAnnual,
productTier: plan.productTier,
};
});
return planCards.reverse();
}
}

View File

@@ -0,0 +1,155 @@
import { Injectable } from "@angular/core";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
import { TaxInformation } from "@bitwarden/common/billing/models/domain/tax-information";
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.component";
@Injectable({
providedIn: "root",
})
export class PricingSummaryService {
private estimatedTax: number = 0;
constructor(private taxService: TaxServiceAbstraction) {}
async getPricingSummaryData(
plan: PlanResponse,
sub: OrganizationSubscriptionResponse,
organization: Organization,
selectedInterval: PlanInterval,
taxInformation: TaxInformation,
isSecretsManagerTrial: boolean,
): Promise<PricingSummaryData> {
// Calculation helpers
const passwordManagerSeatTotal =
plan.PasswordManager?.hasAdditionalSeatsOption && !isSecretsManagerTrial
? plan.PasswordManager.seatPrice * Math.abs(sub?.seats || 0)
: 0;
const secretsManagerSeatTotal = plan.SecretsManager?.hasAdditionalSeatsOption
? plan.SecretsManager.seatPrice * Math.abs(sub?.smSeats || 0)
: 0;
const additionalServiceAccount = this.getAdditionalServiceAccount(plan, sub);
const additionalStorageTotal = plan.PasswordManager?.hasAdditionalStorageOption
? plan.PasswordManager.additionalStoragePricePerGb *
(sub?.maxStorageGb ? sub.maxStorageGb - 1 : 0)
: 0;
const additionalStoragePriceMonthly = plan.PasswordManager?.additionalStoragePricePerGb || 0;
const additionalServiceAccountTotal =
plan.SecretsManager?.hasAdditionalServiceAccountOption && additionalServiceAccount > 0
? plan.SecretsManager.additionalPricePerServiceAccount * additionalServiceAccount
: 0;
let passwordManagerSubtotal = plan.PasswordManager?.basePrice || 0;
if (plan.PasswordManager?.hasAdditionalSeatsOption) {
passwordManagerSubtotal += passwordManagerSeatTotal;
}
if (plan.PasswordManager?.hasPremiumAccessOption) {
passwordManagerSubtotal += plan.PasswordManager.premiumAccessOptionPrice;
}
const secretsManagerSubtotal = plan.SecretsManager
? (plan.SecretsManager.basePrice || 0) +
secretsManagerSeatTotal +
additionalServiceAccountTotal
: 0;
const totalAppliedDiscount = 0;
const discountPercentageFromSub = isSecretsManagerTrial
? 0
: (sub?.customerDiscount?.percentOff ?? 0);
const discountPercentage = 20;
const acceptingSponsorship = false;
const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0;
this.estimatedTax = await this.getEstimatedTax(organization, plan, sub, taxInformation);
const total = organization?.useSecretsManager
? passwordManagerSubtotal +
additionalStorageTotal +
secretsManagerSubtotal +
this.estimatedTax
: passwordManagerSubtotal + additionalStorageTotal + this.estimatedTax;
return {
selectedPlanInterval: selectedInterval === PlanInterval.Annually ? "year" : "month",
passwordManagerSeats:
plan.productTier === ProductTierType.Families ? plan.PasswordManager.baseSeats : sub?.seats,
passwordManagerSeatTotal,
secretsManagerSeatTotal,
additionalStorageTotal,
additionalStoragePriceMonthly,
additionalServiceAccountTotal,
totalAppliedDiscount,
secretsManagerSubtotal,
passwordManagerSubtotal,
total,
organization,
sub,
selectedPlan: plan,
selectedInterval,
discountPercentageFromSub,
discountPercentage,
acceptingSponsorship,
additionalServiceAccount,
storageGb,
isSecretsManagerTrial,
estimatedTax: this.estimatedTax,
};
}
async getEstimatedTax(
organization: Organization,
currentPlan: PlanResponse,
sub: OrganizationSubscriptionResponse,
taxInformation: TaxInformation,
) {
if (!taxInformation || !taxInformation.country || !taxInformation.postalCode) {
return 0;
}
const request: PreviewOrganizationInvoiceRequest = {
organizationId: organization.id,
passwordManager: {
additionalStorage: 0,
plan: currentPlan?.type,
seats: sub.seats,
},
taxInformation: {
postalCode: taxInformation.postalCode,
country: taxInformation.country,
taxId: taxInformation.taxId,
},
};
if (organization.useSecretsManager) {
request.secretsManager = {
seats: sub.smSeats ?? 0,
additionalMachineAccounts:
(sub.smServiceAccounts ?? 0) - (sub.plan.SecretsManager?.baseServiceAccount ?? 0),
};
}
const invoiceResponse = await this.taxService.previewOrganizationInvoice(request);
return invoiceResponse.taxAmount;
}
getAdditionalServiceAccount(plan: PlanResponse, sub: OrganizationSubscriptionResponse): number {
if (!plan || !plan.SecretsManager) {
return 0;
}
const baseServiceAccount = plan.SecretsManager?.baseServiceAccount || 0;
const usedServiceAccounts = sub?.smServiceAccounts || 0;
const additionalServiceAccounts = baseServiceAccount - usedServiceAccounts;
return additionalServiceAccounts <= 0 ? Math.abs(additionalServiceAccounts) : 0;
}
}

View File

@@ -28,7 +28,7 @@
>
<bit-option
[disabled]="true"
[value]=""
[value]="null"
[label]="'--' + ('select' | i18n) + '--'"
></bit-option>
<bit-option

View File

@@ -12,10 +12,13 @@ import { BillingHistoryComponent } from "./billing-history.component";
import { OffboardingSurveyComponent } from "./offboarding-survey.component";
import { PaymentComponent } from "./payment/payment.component";
import { PaymentMethodComponent } from "./payment-method.component";
import { PlanCardComponent } from "./plan-card/plan-card.component";
import { PricingSummaryComponent } from "./pricing-summary/pricing-summary.component";
import { IndividualSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/individual-self-hosting-license-uploader.component";
import { OrganizationSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/organization-self-hosting-license-uploader.component";
import { SecretsManagerSubscribeComponent } from "./sm-subscribe.component";
import { TaxInfoComponent } from "./tax-info.component";
import { TrialPaymentDialogComponent } from "./trial-payment-dialog/trial-payment-dialog.component";
import { UpdateLicenseDialogComponent } from "./update-license-dialog.component";
import { UpdateLicenseComponent } from "./update-license.component";
import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-account.component";
@@ -41,6 +44,9 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
AdjustStorageDialogComponent,
IndividualSelfHostingLicenseUploaderComponent,
OrganizationSelfHostingLicenseUploaderComponent,
TrialPaymentDialogComponent,
PlanCardComponent,
PricingSummaryComponent,
],
exports: [
SharedModule,

View File

@@ -0,0 +1,41 @@
@let isFocused = plan().isSelected;
@let isRecommended = plan().isAnnual;
<bit-card
class="tw-h-full"
[ngClass]="getPlanCardContainerClasses()"
(click)="cardClicked.emit()"
[attr.tabindex]="!isFocused || plan().isDisabled ? '-1' : '0'"
[attr.data-selected]="plan()?.isSelected"
>
<div class="tw-relative">
@if (isRecommended) {
<div
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1"
[ngClass]="{
'tw-bg-primary-700 !tw-text-contrast': plan().isSelected,
'tw-bg-secondary-100': !plan().isSelected,
}"
>
{{ "recommended" | i18n }}
</div>
}
<div
class="tw-px-2 tw-pb-[4px]"
[ngClass]="{
'tw-py-1': !plan().isSelected,
'tw-py-0': plan().isSelected,
}"
>
<h3
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
>
<span class="tw-capitalize tw-whitespace-nowrap">{{ plan().title }}</span>
</h3>
<span>
<b class="tw-text-lg tw-font-semibold">{{ plan().costPerMember | currency: "$" }} </b>
<span class="tw-text-xs tw-px-0"> /{{ "monthPerMember" | i18n }}</span>
</span>
</div>
</div>
</bit-card>

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