mirror of
https://github.com/bitwarden/browser
synced 2026-02-26 17:43:22 +00:00
Fixed conflicts
This commit is contained in:
@@ -58,6 +58,7 @@ describe("ORGANIZATIONS state", () => {
|
||||
familySponsorshipLastSyncDate: new Date(),
|
||||
userIsManagedByOrganization: false,
|
||||
useRiskInsights: false,
|
||||
useAdminSponsoredFamilies: false,
|
||||
},
|
||||
};
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));
|
||||
|
||||
@@ -60,6 +60,7 @@ export class OrganizationData {
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
|
||||
constructor(
|
||||
response?: ProfileOrganizationResponse,
|
||||
@@ -122,6 +123,7 @@ export class OrganizationData {
|
||||
this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems;
|
||||
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
|
||||
this.useRiskInsights = response.useRiskInsights;
|
||||
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
|
||||
|
||||
this.isMember = options.isMember;
|
||||
this.isProviderUser = options.isProviderUser;
|
||||
|
||||
@@ -90,6 +90,7 @@ export class Organization {
|
||||
*/
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
|
||||
constructor(obj?: OrganizationData) {
|
||||
if (obj == null) {
|
||||
@@ -148,6 +149,7 @@ export class Organization {
|
||||
this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems;
|
||||
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
|
||||
this.useRiskInsights = obj.useRiskInsights;
|
||||
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
|
||||
}
|
||||
|
||||
get canAccess() {
|
||||
|
||||
@@ -6,4 +6,5 @@ export class OrganizationSponsorshipCreateRequest {
|
||||
sponsoredEmail: string;
|
||||
planSponsorshipType: PlanSponsorshipType;
|
||||
friendlyName: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -121,5 +122,6 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
);
|
||||
this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization");
|
||||
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
|
||||
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ export class DeviceResponse extends BaseResponse {
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
isTrusted: boolean;
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
|
||||
devicePendingAuthRequest: DevicePendingAuthRequest | null;
|
||||
|
||||
constructor(response: any) {
|
||||
@@ -27,6 +30,8 @@ export class DeviceResponse extends BaseResponse {
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
this.revisionDate = this.getResponseProperty("RevisionDate");
|
||||
this.isTrusted = this.getResponseProperty("IsTrusted");
|
||||
this.encryptedUserKey = this.getResponseProperty("EncryptedUserKey");
|
||||
this.encryptedPublicKey = this.getResponseProperty("EncryptedPublicKey");
|
||||
this.devicePendingAuthRequest = this.getResponseProperty("DevicePendingAuthRequest");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
|
||||
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
|
||||
import { InitiationPath } from "../../models/request/reference-event.request";
|
||||
@@ -59,4 +62,10 @@ export abstract class OrganizationBillingServiceAbstraction {
|
||||
organizationId: string,
|
||||
subscription: SubscriptionInformation,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Determines if breadcrumbing policies is enabled for the organizations meeting certain criteria.
|
||||
* @param organization
|
||||
*/
|
||||
abstract isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable<boolean>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PlanType } from "../../enums";
|
||||
import { PlanSponsorshipType, PlanType } from "../../enums";
|
||||
|
||||
export class PreviewOrganizationInvoiceRequest {
|
||||
organizationId?: string;
|
||||
@@ -21,6 +21,7 @@ export class PreviewOrganizationInvoiceRequest {
|
||||
|
||||
class PasswordManager {
|
||||
plan: PlanType;
|
||||
sponsoredPlan?: PlanSponsorshipType;
|
||||
seats: number;
|
||||
additionalStorage: number;
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction as OrganizationApiService } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
describe("BillingAccountProfileStateService", () => {
|
||||
let apiService: jest.Mocked<ApiService>;
|
||||
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
|
||||
let keyService: jest.Mocked<KeyService>;
|
||||
let encryptService: jest.Mocked<EncryptService>;
|
||||
let i18nService: jest.Mocked<I18nService>;
|
||||
let organizationApiService: jest.Mocked<OrganizationApiService>;
|
||||
let syncService: jest.Mocked<SyncService>;
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
|
||||
let sut: OrganizationBillingService;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||
keyService = mock<KeyService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
i18nService = mock<I18nService>();
|
||||
organizationApiService = mock<OrganizationApiService>();
|
||||
syncService = mock<SyncService>();
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
sut = new OrganizationBillingService(
|
||||
apiService,
|
||||
billingApiService,
|
||||
keyService,
|
||||
encryptService,
|
||||
i18nService,
|
||||
organizationApiService,
|
||||
syncService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
return jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("isBreadcrumbingPoliciesEnabled", () => {
|
||||
it("returns false when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
const org = {
|
||||
isProviderUser: false,
|
||||
canEditSubscription: true,
|
||||
productTierType: ProductTierType.Teams,
|
||||
} as Organization;
|
||||
|
||||
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(actual).toBe(false);
|
||||
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM12276_BreadcrumbEventLogs,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when organization belongs to a provider", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
const org = {
|
||||
isProviderUser: true,
|
||||
canEditSubscription: true,
|
||||
productTierType: ProductTierType.Teams,
|
||||
} as Organization;
|
||||
|
||||
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when cannot edit subscription", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
const org = {
|
||||
isProviderUser: false,
|
||||
canEditSubscription: false,
|
||||
productTierType: ProductTierType.Teams,
|
||||
} as Organization;
|
||||
|
||||
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Teams", ProductTierType.Teams],
|
||||
["TeamsStarter", ProductTierType.TeamsStarter],
|
||||
])("returns true when all conditions are met with %s tier", async (_, productTierType) => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
const org = {
|
||||
isProviderUser: false,
|
||||
canEditSubscription: true,
|
||||
productTierType: productTierType,
|
||||
} as Organization;
|
||||
|
||||
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(actual).toBe(true);
|
||||
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM12276_BreadcrumbEventLogs,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when product tier is not supported", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
const org = {
|
||||
isProviderUser: false,
|
||||
canEditSubscription: true,
|
||||
productTierType: ProductTierType.Enterprise,
|
||||
} as Organization;
|
||||
|
||||
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it("handles all conditions false correctly", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
const org = {
|
||||
isProviderUser: true,
|
||||
canEditSubscription: false,
|
||||
productTierType: ProductTierType.Free,
|
||||
} as Organization;
|
||||
|
||||
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies feature flag is only called once", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
const org = {
|
||||
isProviderUser: false,
|
||||
canEditSubscription: true,
|
||||
productTierType: ProductTierType.Teams,
|
||||
} as Organization;
|
||||
|
||||
await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(configService.getFeatureFlag$).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable, of, switchMap } from "rxjs";
|
||||
|
||||
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 { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
@@ -20,7 +25,7 @@ import {
|
||||
PlanInformation,
|
||||
SubscriptionInformation,
|
||||
} from "../abstractions";
|
||||
import { PlanType } from "../enums";
|
||||
import { PlanType, ProductTierType } from "../enums";
|
||||
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
|
||||
import { PaymentSourceResponse } from "../models/response/payment-source.response";
|
||||
|
||||
@@ -40,6 +45,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
private i18nService: I18nService,
|
||||
private organizationApiService: OrganizationApiService,
|
||||
private syncService: SyncService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async getPaymentSource(organizationId: string): Promise<PaymentSourceResponse> {
|
||||
@@ -220,4 +226,29 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
this.setPaymentInformation(request, subscription.payment);
|
||||
await this.billingApiService.restartSubscription(organizationId, request);
|
||||
}
|
||||
|
||||
isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable<boolean> {
|
||||
if (organization === null || organization === undefined) {
|
||||
return of(false);
|
||||
}
|
||||
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs).pipe(
|
||||
switchMap((featureFlagEnabled) => {
|
||||
if (!featureFlagEnabled) {
|
||||
return of(false);
|
||||
}
|
||||
|
||||
if (organization.isProviderUser || !organization.canEditSubscription) {
|
||||
return of(false);
|
||||
}
|
||||
|
||||
const supportedProducts = [ProductTierType.Teams, ProductTierType.TeamsStarter];
|
||||
const isSupportedProduct = supportedProducts.some(
|
||||
(product) => product === organization.productTierType,
|
||||
);
|
||||
|
||||
return of(isSupportedProduct);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ export enum FeatureFlag {
|
||||
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
|
||||
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
|
||||
IdpAutoSubmitLogin = "idp-auto-submit-login",
|
||||
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
|
||||
NotificationRefresh = "notification-refresh",
|
||||
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
@@ -34,6 +33,7 @@ export enum FeatureFlag {
|
||||
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
|
||||
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
|
||||
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
|
||||
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
@@ -46,7 +46,6 @@ export enum FeatureFlag {
|
||||
CriticalApps = "pm-14466-risk-insights-critical-application",
|
||||
EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
|
||||
DesktopSendUIRefresh = "desktop-send-ui-refresh",
|
||||
ExportAttachments = "export-attachments",
|
||||
|
||||
/* Vault */
|
||||
PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge",
|
||||
@@ -57,6 +56,7 @@ export enum FeatureFlag {
|
||||
SecurityTasks = "security-tasks",
|
||||
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
EndUserNotifications = "pm-10609-end-user-notifications",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
@@ -89,7 +89,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
|
||||
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
|
||||
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
|
||||
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
|
||||
[FeatureFlag.NotificationRefresh]: FALSE,
|
||||
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
@@ -99,7 +98,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.CriticalApps]: FALSE,
|
||||
[FeatureFlag.EnableRiskInsightsNotifications]: FALSE,
|
||||
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
|
||||
[FeatureFlag.ExportAttachments]: FALSE,
|
||||
|
||||
/* Vault */
|
||||
[FeatureFlag.PM8851_BrowserOnboardingNudge]: FALSE,
|
||||
@@ -109,6 +107,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.SecurityTasks]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
[FeatureFlag.EndUserNotifications]: FALSE,
|
||||
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
@@ -119,6 +118,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
|
||||
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
|
||||
[FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE,
|
||||
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
|
||||
@@ -47,7 +47,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
}
|
||||
|
||||
if (this.blockType0) {
|
||||
if (key.encType === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
}
|
||||
|
||||
if (this.blockType0) {
|
||||
if (key.encType === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
if (encString.encryptionType !== innerKey.type) {
|
||||
this.logDecryptError(
|
||||
"Key encryption type does not match payload encryption type",
|
||||
key.encType,
|
||||
innerKey.type,
|
||||
encString.encryptionType,
|
||||
decryptContext,
|
||||
);
|
||||
@@ -148,7 +148,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
if (!macsEqual) {
|
||||
this.logMacFailed(
|
||||
"decryptToUtf8 MAC comparison failed. Key or payload has changed.",
|
||||
key.encType,
|
||||
innerKey.type,
|
||||
encString.encryptionType,
|
||||
decryptContext,
|
||||
);
|
||||
@@ -191,7 +191,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
if (encThing.encryptionType !== inner.type) {
|
||||
this.logDecryptError(
|
||||
"Encryption key type mismatch",
|
||||
key.encType,
|
||||
inner.type,
|
||||
encThing.encryptionType,
|
||||
decryptContext,
|
||||
);
|
||||
@@ -200,19 +200,23 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
|
||||
if (inner.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
if (encThing.macBytes == null) {
|
||||
this.logDecryptError("Mac missing", key.encType, encThing.encryptionType, decryptContext);
|
||||
this.logDecryptError("Mac missing", inner.type, encThing.encryptionType, decryptContext);
|
||||
return null;
|
||||
}
|
||||
|
||||
const macData = new Uint8Array(encThing.ivBytes.byteLength + encThing.dataBytes.byteLength);
|
||||
macData.set(new Uint8Array(encThing.ivBytes), 0);
|
||||
macData.set(new Uint8Array(encThing.dataBytes), encThing.ivBytes.byteLength);
|
||||
const computedMac = await this.cryptoFunctionService.hmac(macData, key.macKey, "sha256");
|
||||
const computedMac = await this.cryptoFunctionService.hmac(
|
||||
macData,
|
||||
inner.authenticationKey,
|
||||
"sha256",
|
||||
);
|
||||
const macsMatch = await this.cryptoFunctionService.compare(encThing.macBytes, computedMac);
|
||||
if (!macsMatch) {
|
||||
this.logMacFailed(
|
||||
"MAC comparison failed. Key or payload has changed.",
|
||||
key.encType,
|
||||
inner.type,
|
||||
encThing.encryptionType,
|
||||
decryptContext,
|
||||
);
|
||||
@@ -222,14 +226,14 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
return await this.cryptoFunctionService.aesDecrypt(
|
||||
encThing.dataBytes,
|
||||
encThing.ivBytes,
|
||||
key.encKey,
|
||||
inner.encryptionKey,
|
||||
"cbc",
|
||||
);
|
||||
} else if (inner.type === EncryptionType.AesCbc256_B64) {
|
||||
return await this.cryptoFunctionService.aesDecrypt(
|
||||
encThing.dataBytes,
|
||||
encThing.ivBytes,
|
||||
key.encKey,
|
||||
inner.encryptionKey,
|
||||
"cbc",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
Aes256CbcHmacKey,
|
||||
SymmetricCryptoKey,
|
||||
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
@@ -64,6 +67,10 @@ describe("EncryptService", () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: mock32Key.key,
|
||||
});
|
||||
|
||||
await expect(encryptService.encrypt(null!, key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
@@ -146,6 +153,10 @@ describe("EncryptService", () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: mock32Key.key,
|
||||
});
|
||||
|
||||
await expect(encryptService.encryptToBytes(plainValue, key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
@@ -228,7 +239,7 @@ describe("EncryptService", () => {
|
||||
expect(cryptoFunctionService.aesDecrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(encBuffer.dataBytes),
|
||||
expect.toEqualBuffer(encBuffer.ivBytes),
|
||||
expect.toEqualBuffer(key.encKey),
|
||||
expect.toEqualBuffer(key.inner().encryptionKey),
|
||||
"cbc",
|
||||
);
|
||||
|
||||
@@ -249,7 +260,7 @@ describe("EncryptService", () => {
|
||||
expect(cryptoFunctionService.aesDecrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(encBuffer.dataBytes),
|
||||
expect.toEqualBuffer(encBuffer.ivBytes),
|
||||
expect.toEqualBuffer(key.encKey),
|
||||
expect.toEqualBuffer(key.inner().encryptionKey),
|
||||
"cbc",
|
||||
);
|
||||
|
||||
@@ -267,7 +278,7 @@ describe("EncryptService", () => {
|
||||
|
||||
expect(cryptoFunctionService.hmac).toBeCalledWith(
|
||||
expect.toEqualBuffer(expectedMacData),
|
||||
key.macKey,
|
||||
(key.inner() as Aes256CbcHmacKey).authenticationKey,
|
||||
"sha256",
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as argon2 from "argon2-browser";
|
||||
import * as forge from "node-forge";
|
||||
|
||||
import { EncryptionType } from "../../../platform/enums";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import {
|
||||
CbcDecryptParameters,
|
||||
@@ -247,37 +248,26 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
mac: string | null,
|
||||
key: SymmetricCryptoKey,
|
||||
): CbcDecryptParameters<string> {
|
||||
const p = {} as CbcDecryptParameters<string>;
|
||||
if (key.meta != null) {
|
||||
p.encKey = key.meta.encKeyByteString;
|
||||
p.macKey = key.meta.macKeyByteString;
|
||||
const innerKey = key.inner();
|
||||
if (innerKey.type === EncryptionType.AesCbc256_B64) {
|
||||
return {
|
||||
iv: forge.util.decode64(iv),
|
||||
data: forge.util.decode64(data),
|
||||
encKey: forge.util.createBuffer(innerKey.encryptionKey).getBytes(),
|
||||
} as CbcDecryptParameters<string>;
|
||||
} else if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const macData = forge.util.decode64(iv) + forge.util.decode64(data);
|
||||
return {
|
||||
iv: forge.util.decode64(iv),
|
||||
data: forge.util.decode64(data),
|
||||
encKey: forge.util.createBuffer(innerKey.encryptionKey).getBytes(),
|
||||
macKey: forge.util.createBuffer(innerKey.authenticationKey).getBytes(),
|
||||
mac: forge.util.decode64(mac!),
|
||||
macData,
|
||||
} as CbcDecryptParameters<string>;
|
||||
} else {
|
||||
throw new Error("Unsupported encryption type.");
|
||||
}
|
||||
|
||||
if (p.encKey == null) {
|
||||
p.encKey = forge.util.decode64(key.encKeyB64);
|
||||
}
|
||||
p.data = forge.util.decode64(data);
|
||||
p.iv = forge.util.decode64(iv);
|
||||
p.macData = p.iv + p.data;
|
||||
if (p.macKey == null && key.macKeyB64 != null) {
|
||||
p.macKey = forge.util.decode64(key.macKeyB64);
|
||||
}
|
||||
if (mac != null) {
|
||||
p.mac = forge.util.decode64(mac);
|
||||
}
|
||||
|
||||
// cache byte string keys for later
|
||||
if (key.meta == null) {
|
||||
key.meta = {};
|
||||
}
|
||||
if (key.meta.encKeyByteString == null) {
|
||||
key.meta.encKeyByteString = p.encKey;
|
||||
}
|
||||
if (p.macKey != null && key.meta.macKeyByteString == null) {
|
||||
key.meta.macKeyByteString = p.macKey;
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
aesDecryptFast({
|
||||
|
||||
@@ -209,9 +209,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
devices.data
|
||||
.filter((device) => device.isTrusted)
|
||||
.map(async (device) => {
|
||||
const deviceWithKeys = await this.devicesApiService.getDeviceKeys(device.identifier);
|
||||
const publicKey = await this.encryptService.decryptToBytes(
|
||||
deviceWithKeys.encryptedPublicKey,
|
||||
new EncString(device.encryptedPublicKey),
|
||||
oldUserKey,
|
||||
);
|
||||
|
||||
|
||||
@@ -252,7 +252,9 @@ describe("KeyConnectorService", () => {
|
||||
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
|
||||
const masterKey = getMockMasterKey();
|
||||
masterPasswordService.masterKeySubject.next(masterKey);
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(
|
||||
Utils.fromBufferToB64(masterKey.inner().encryptionKey),
|
||||
);
|
||||
|
||||
jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization);
|
||||
jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue();
|
||||
@@ -273,7 +275,9 @@ describe("KeyConnectorService", () => {
|
||||
// Arrange
|
||||
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
|
||||
const masterKey = getMockMasterKey();
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(
|
||||
Utils.fromBufferToB64(masterKey.inner().encryptionKey),
|
||||
);
|
||||
const error = new Error("Failed to post user key to key connector");
|
||||
organizationService.organizations$.mockReturnValue(of([organization]));
|
||||
|
||||
|
||||
@@ -95,7 +95,9 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
const organization = await this.getManagingOrganization(userId);
|
||||
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(
|
||||
Utils.fromBufferToB64(masterKey.inner().encryptionKey),
|
||||
);
|
||||
|
||||
try {
|
||||
await this.apiService.postUserKeyToKeyConnector(
|
||||
@@ -157,7 +159,9 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
await this.tokenService.getEmail(),
|
||||
kdfConfig,
|
||||
);
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(
|
||||
Utils.fromBufferToB64(masterKey.inner().encryptionKey),
|
||||
);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
|
||||
const userKey = await this.keyService.makeUserKey(masterKey);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { NotificationViewResponse as EndUserNotificationResponse } from "@bitwarden/common/vault/notifications/models";
|
||||
|
||||
import { NotificationType } from "../../enums";
|
||||
|
||||
import { BaseResponse } from "./base.response";
|
||||
@@ -57,6 +59,10 @@ export class NotificationResponse extends BaseResponse {
|
||||
case NotificationType.SyncOrganizationCollectionSettingChanged:
|
||||
this.payload = new OrganizationCollectionSettingChangedPushNotification(payload);
|
||||
break;
|
||||
case NotificationType.Notification:
|
||||
case NotificationType.NotificationStatus:
|
||||
this.payload = new EndUserNotificationResponse(payload);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
// See https://contributing.bitwarden.com/architecture/clients/data-model/#view for proper use.
|
||||
// View models represent the decrypted state of a corresponding Domain model.
|
||||
// They typically match the Domain model but contains a decrypted string for any EncString fields.
|
||||
// Don't use this to represent arbitrary component view data as that isn't what it is for.
|
||||
export class View {}
|
||||
|
||||
@@ -706,4 +706,73 @@ describe("Utils Service", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromUtf8ToB64(...)", () => {
|
||||
const originalIsNode = Utils.isNode;
|
||||
|
||||
afterEach(() => {
|
||||
Utils.isNode = originalIsNode;
|
||||
});
|
||||
|
||||
runInBothEnvironments("should handle empty string", () => {
|
||||
const str = Utils.fromUtf8ToB64("");
|
||||
expect(str).toBe("");
|
||||
});
|
||||
|
||||
runInBothEnvironments("should convert a normal b64 string", () => {
|
||||
const str = Utils.fromUtf8ToB64(asciiHelloWorld);
|
||||
expect(str).toBe(b64HelloWorldString);
|
||||
});
|
||||
|
||||
runInBothEnvironments("should convert various special characters", () => {
|
||||
const cases = [
|
||||
{ input: "»", output: "wrs=" },
|
||||
{ input: "¦", output: "wqY=" },
|
||||
{ input: "£", output: "wqM=" },
|
||||
{ input: "é", output: "w6k=" },
|
||||
{ input: "ö", output: "w7Y=" },
|
||||
{ input: "»»", output: "wrvCuw==" },
|
||||
];
|
||||
cases.forEach((c) => {
|
||||
const utfStr = c.input;
|
||||
const str = Utils.fromUtf8ToB64(utfStr);
|
||||
expect(str).toBe(c.output);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromB64ToUtf8(...)", () => {
|
||||
const originalIsNode = Utils.isNode;
|
||||
|
||||
afterEach(() => {
|
||||
Utils.isNode = originalIsNode;
|
||||
});
|
||||
|
||||
runInBothEnvironments("should handle empty string", () => {
|
||||
const str = Utils.fromB64ToUtf8("");
|
||||
expect(str).toBe("");
|
||||
});
|
||||
|
||||
runInBothEnvironments("should convert a normal b64 string", () => {
|
||||
const str = Utils.fromB64ToUtf8(b64HelloWorldString);
|
||||
expect(str).toBe(asciiHelloWorld);
|
||||
});
|
||||
|
||||
runInBothEnvironments("should handle various special characters", () => {
|
||||
const cases = [
|
||||
{ input: "wrs=", output: "»" },
|
||||
{ input: "wqY=", output: "¦" },
|
||||
{ input: "wqM=", output: "£" },
|
||||
{ input: "w6k=", output: "é" },
|
||||
{ input: "w7Y=", output: "ö" },
|
||||
{ input: "wrvCuw==", output: "»»" },
|
||||
];
|
||||
|
||||
cases.forEach((c) => {
|
||||
const b64Str = c.input;
|
||||
const str = Utils.fromB64ToUtf8(b64Str);
|
||||
expect(str).toBe(c.output);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,7 +233,7 @@ export class Utils {
|
||||
if (Utils.isNode) {
|
||||
return Buffer.from(utfStr, "utf8").toString("base64");
|
||||
} else {
|
||||
return decodeURIComponent(escape(Utils.global.btoa(utfStr)));
|
||||
return BufferLib.from(utfStr, "utf8").toString("base64");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ export class Utils {
|
||||
if (Utils.isNode) {
|
||||
return Buffer.from(b64Str, "base64").toString("utf8");
|
||||
} else {
|
||||
return decodeURIComponent(escape(Utils.global.atob(b64Str)));
|
||||
return BufferLib.from(b64Str, "base64").toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { makeStaticByteArray } from "../../../../spec";
|
||||
import { EncryptionType } from "../../enums";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
import { SymmetricCryptoKey } from "./symmetric-crypto-key";
|
||||
import { Aes256CbcHmacKey, SymmetricCryptoKey } from "./symmetric-crypto-key";
|
||||
|
||||
describe("SymmetricCryptoKey", () => {
|
||||
it("errors if no key", () => {
|
||||
@@ -19,13 +19,8 @@ describe("SymmetricCryptoKey", () => {
|
||||
const cryptoKey = new SymmetricCryptoKey(key);
|
||||
|
||||
expect(cryptoKey).toEqual({
|
||||
encKey: key,
|
||||
encKeyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
encType: EncryptionType.AesCbc256_B64,
|
||||
key: key,
|
||||
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
macKey: null,
|
||||
macKeyB64: undefined,
|
||||
innerKey: {
|
||||
type: EncryptionType.AesCbc256_B64,
|
||||
encryptionKey: key,
|
||||
@@ -38,14 +33,9 @@ describe("SymmetricCryptoKey", () => {
|
||||
const cryptoKey = new SymmetricCryptoKey(key);
|
||||
|
||||
expect(cryptoKey).toEqual({
|
||||
encKey: key.slice(0, 32),
|
||||
encKeyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
encType: EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
key: key,
|
||||
keyB64:
|
||||
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+Pw==",
|
||||
macKey: key.slice(32, 64),
|
||||
macKeyB64: "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=",
|
||||
innerKey: {
|
||||
type: EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
encryptionKey: key.slice(0, 32),
|
||||
@@ -86,8 +76,8 @@ describe("SymmetricCryptoKey", () => {
|
||||
|
||||
expect(actual).toEqual({
|
||||
type: EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
encryptionKey: key.encKey,
|
||||
authenticationKey: key.macKey,
|
||||
encryptionKey: key.inner().encryptionKey,
|
||||
authenticationKey: (key.inner() as Aes256CbcHmacKey).authenticationKey,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,7 +85,7 @@ describe("SymmetricCryptoKey", () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const actual = key.toEncoded();
|
||||
|
||||
expect(actual).toEqual(key.encKey);
|
||||
expect(actual).toEqual(key.inner().encryptionKey);
|
||||
});
|
||||
|
||||
it("toEncoded returns encoded key for AesCbc256_HmacSha256_B64", () => {
|
||||
|
||||
@@ -25,15 +25,7 @@ export class SymmetricCryptoKey {
|
||||
private innerKey: Aes256CbcHmacKey | Aes256CbcKey;
|
||||
|
||||
key: Uint8Array;
|
||||
encKey: Uint8Array;
|
||||
macKey?: Uint8Array;
|
||||
encType: EncryptionType;
|
||||
|
||||
keyB64: string;
|
||||
encKeyB64: string;
|
||||
macKeyB64: string;
|
||||
|
||||
meta: any;
|
||||
|
||||
/**
|
||||
* @param key The key in one of the permitted serialization formats
|
||||
@@ -48,30 +40,16 @@ export class SymmetricCryptoKey {
|
||||
type: EncryptionType.AesCbc256_B64,
|
||||
encryptionKey: key,
|
||||
};
|
||||
this.encType = EncryptionType.AesCbc256_B64;
|
||||
this.key = key;
|
||||
this.keyB64 = Utils.fromBufferToB64(this.key);
|
||||
|
||||
this.encKey = key;
|
||||
this.encKeyB64 = Utils.fromBufferToB64(this.encKey);
|
||||
|
||||
this.macKey = null;
|
||||
this.macKeyB64 = undefined;
|
||||
this.keyB64 = this.toBase64();
|
||||
} else if (key.byteLength === 64) {
|
||||
this.innerKey = {
|
||||
type: EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
encryptionKey: key.slice(0, 32),
|
||||
authenticationKey: key.slice(32),
|
||||
};
|
||||
this.encType = EncryptionType.AesCbc256_HmacSha256_B64;
|
||||
this.key = key;
|
||||
this.keyB64 = Utils.fromBufferToB64(this.key);
|
||||
|
||||
this.encKey = key.slice(0, 32);
|
||||
this.encKeyB64 = Utils.fromBufferToB64(this.encKey);
|
||||
|
||||
this.macKey = key.slice(32);
|
||||
this.macKeyB64 = Utils.fromBufferToB64(this.macKey);
|
||||
this.keyB64 = this.toBase64();
|
||||
} else {
|
||||
throw new Error(`Unsupported encType/key length ${key.byteLength}`);
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ class MyWebPushConnector implements WebPushConnector {
|
||||
|
||||
private async pushManagerSubscribe(key: string) {
|
||||
return await this.serviceWorkerRegistration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
userVisibleOnly: false,
|
||||
applicationServerKey: key,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { NotificationView } from "../models";
|
||||
|
||||
/**
|
||||
* A service for retrieving and managing notifications for end users.
|
||||
*/
|
||||
export abstract class EndUserNotificationService {
|
||||
/**
|
||||
* Observable of all notifications for the given user.
|
||||
* @param userId
|
||||
*/
|
||||
abstract notifications$(userId: UserId): Observable<NotificationView[]>;
|
||||
|
||||
/**
|
||||
* Observable of all unread notifications for the given user.
|
||||
* @param userId
|
||||
*/
|
||||
abstract unreadNotifications$(userId: UserId): Observable<NotificationView[]>;
|
||||
|
||||
/**
|
||||
* Mark a notification as read.
|
||||
* @param notificationId
|
||||
* @param userId
|
||||
*/
|
||||
abstract markAsRead(notificationId: NotificationId, userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Mark a notification as deleted.
|
||||
* @param notificationId
|
||||
* @param userId
|
||||
*/
|
||||
abstract markAsDeleted(notificationId: NotificationId, userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clear all notifications from state for the given user.
|
||||
* @param userId
|
||||
*/
|
||||
abstract clearState(userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Creates a subscription to listen for end user push notifications and notification status updates.
|
||||
*/
|
||||
abstract listenForEndUserNotifications(): Subscription;
|
||||
}
|
||||
2
libs/common/src/vault/notifications/index.ts
Normal file
2
libs/common/src/vault/notifications/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { EndUserNotificationService } from "./abstractions/end-user-notification.service";
|
||||
export { DefaultEndUserNotificationService } from "./services/default-end-user-notification.service";
|
||||
3
libs/common/src/vault/notifications/models/index.ts
Normal file
3
libs/common/src/vault/notifications/models/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./notification-view";
|
||||
export * from "./notification-view.data";
|
||||
export * from "./notification-view.response";
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { NotificationViewResponse } from "./notification-view.response";
|
||||
|
||||
export class NotificationViewData {
|
||||
id: NotificationId;
|
||||
priority: number;
|
||||
title: string;
|
||||
body: string;
|
||||
date: Date;
|
||||
taskId?: SecurityTaskId;
|
||||
readDate: Date | null;
|
||||
deletedDate: Date | null;
|
||||
|
||||
constructor(response: NotificationViewResponse) {
|
||||
this.id = response.id;
|
||||
this.priority = response.priority;
|
||||
this.title = response.title;
|
||||
this.body = response.body;
|
||||
this.date = response.date;
|
||||
this.taskId = response.taskId;
|
||||
this.readDate = response.readDate;
|
||||
this.deletedDate = response.deletedDate;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<NotificationViewData>) {
|
||||
return Object.assign(new NotificationViewData({} as NotificationViewResponse), obj, {
|
||||
id: obj.id,
|
||||
priority: obj.priority,
|
||||
title: obj.title,
|
||||
body: obj.body,
|
||||
date: new Date(obj.date),
|
||||
taskId: obj.taskId,
|
||||
readDate: obj.readDate ? new Date(obj.readDate) : null,
|
||||
deletedDate: obj.deletedDate ? new Date(obj.deletedDate) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class NotificationViewResponse extends BaseResponse {
|
||||
id: NotificationId;
|
||||
priority: number;
|
||||
title: string;
|
||||
body: string;
|
||||
date: Date;
|
||||
taskId?: SecurityTaskId;
|
||||
readDate: Date;
|
||||
deletedDate: Date;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.priority = this.getResponseProperty("Priority");
|
||||
this.title = this.getResponseProperty("Title");
|
||||
this.body = this.getResponseProperty("Body");
|
||||
this.date = this.getResponseProperty("Date");
|
||||
this.taskId = this.getResponseProperty("TaskId");
|
||||
this.readDate = this.getResponseProperty("ReadDate");
|
||||
this.deletedDate = this.getResponseProperty("DeletedDate");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class NotificationView {
|
||||
id: NotificationId;
|
||||
priority: number;
|
||||
title: string;
|
||||
body: string;
|
||||
date: Date;
|
||||
taskId?: SecurityTaskId;
|
||||
readDate: Date | null;
|
||||
deletedDate: Date | null;
|
||||
|
||||
constructor(obj: any) {
|
||||
this.id = obj.id;
|
||||
this.priority = obj.priority;
|
||||
this.title = obj.title;
|
||||
this.body = obj.body;
|
||||
this.date = obj.date;
|
||||
this.taskId = obj.taskId;
|
||||
this.readDate = obj.readDate;
|
||||
this.deletedDate = obj.deletedDate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { NotificationViewResponse } from "../models";
|
||||
import { NOTIFICATIONS } from "../state/end-user-notification.state";
|
||||
|
||||
import {
|
||||
DEFAULT_NOTIFICATION_PAGE_SIZE,
|
||||
DefaultEndUserNotificationService,
|
||||
} from "./default-end-user-notification.service";
|
||||
|
||||
describe("End User Notification Center Service", () => {
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
let mockApiService: jest.Mocked<ApiService>;
|
||||
let mockNotificationsService: jest.Mocked<NotificationsService>;
|
||||
let mockAuthService: jest.Mocked<AuthService>;
|
||||
let mockLogService: jest.Mocked<LogService>;
|
||||
let service: DefaultEndUserNotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||
mockApiService = {
|
||||
send: jest.fn(),
|
||||
} as any;
|
||||
mockNotificationsService = {
|
||||
notifications$: of(null),
|
||||
} as any;
|
||||
mockAuthService = {
|
||||
authStatuses$: of({}),
|
||||
} as any;
|
||||
mockLogService = mock<LogService>();
|
||||
|
||||
service = new DefaultEndUserNotificationService(
|
||||
fakeStateProvider as unknown as StateProvider,
|
||||
mockApiService,
|
||||
mockNotificationsService,
|
||||
mockAuthService,
|
||||
mockLogService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("notifications$", () => {
|
||||
it("should return notifications from state when not null", async () => {
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
} as NotificationViewResponse,
|
||||
]);
|
||||
|
||||
const result = await firstValueFrom(service.notifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiService.send).not.toHaveBeenCalled();
|
||||
expect(mockLogService.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return notifications API when state is null", async () => {
|
||||
mockApiService.send.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any);
|
||||
|
||||
const result = await firstValueFrom(service.notifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(mockLogService.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log a warning if there are more notifications available", async () => {
|
||||
mockApiService.send.mockResolvedValue({
|
||||
data: [
|
||||
...new Array(DEFAULT_NOTIFICATION_PAGE_SIZE + 1).fill({ id: "notification-id" }),
|
||||
] as NotificationViewResponse[],
|
||||
continuationToken: "next-token", // Presence of continuation token indicates more data
|
||||
});
|
||||
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any);
|
||||
|
||||
const result = await firstValueFrom(service.notifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(DEFAULT_NOTIFICATION_PAGE_SIZE + 1);
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(mockLogService.warning).toHaveBeenCalledWith(
|
||||
`More notifications available, but not fetched. Consider increasing the page size from ${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should share the same observable for the same user", async () => {
|
||||
const first = service.notifications$("user-id" as UserId);
|
||||
const second = service.notifications$("user-id" as UserId);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreadNotifications$", () => {
|
||||
it("should return unread notifications from state when read value is null", async () => {
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
readDate: null as any,
|
||||
} as NotificationViewResponse,
|
||||
]);
|
||||
|
||||
const result = await firstValueFrom(service.unreadNotifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNotifications", () => {
|
||||
it("should call getNotifications returning notifications from API", async () => {
|
||||
mockApiService.send.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
|
||||
await service.refreshNotifications("user-id" as UserId);
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should update local state when notifications are updated", async () => {
|
||||
mockApiService.send.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
|
||||
const mock = fakeStateProvider.singleUser.mockFor(
|
||||
"user-id" as UserId,
|
||||
NOTIFICATIONS,
|
||||
null as any,
|
||||
);
|
||||
|
||||
await service.refreshNotifications("user-id" as UserId);
|
||||
|
||||
expect(mock.nextMock).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
id: "notification-id" as NotificationId,
|
||||
} as NotificationViewResponse),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should clear the local notification state for the user", async () => {
|
||||
const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
} as NotificationViewResponse,
|
||||
]);
|
||||
|
||||
await service.clearState("user-id" as UserId);
|
||||
|
||||
expect(mock.nextMock).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAsDeleted", () => {
|
||||
it("should send an API request to mark the notification as deleted", async () => {
|
||||
await service.markAsDeleted("notification-id" as NotificationId, "user-id" as UserId);
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"DELETE",
|
||||
"/notifications/notification-id/delete",
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAsRead", () => {
|
||||
it("should send an API request to mark the notification as read", async () => {
|
||||
await service.markAsRead("notification-id" as NotificationId, "user-id" as UserId);
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
"/notifications/notification-id/read",
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { concatMap, EMPTY, filter, map, Observable, Subscription, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { NotificationType } from "@bitwarden/common/enums";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
filterOutNullish,
|
||||
perUserCache$,
|
||||
} from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
|
||||
import { EndUserNotificationService } from "../abstractions/end-user-notification.service";
|
||||
import { NotificationView, NotificationViewData, NotificationViewResponse } from "../models";
|
||||
import { NOTIFICATIONS } from "../state/end-user-notification.state";
|
||||
|
||||
/**
|
||||
* The default number of notifications to fetch from the API.
|
||||
*/
|
||||
export const DEFAULT_NOTIFICATION_PAGE_SIZE = 50;
|
||||
|
||||
const getLoggedInUserIds = map<Record<UserId, AuthenticationStatus>, UserId[]>((authStatuses) =>
|
||||
Object.entries(authStatuses ?? {})
|
||||
.filter(([, status]) => status >= AuthenticationStatus.Locked)
|
||||
.map(([userId]) => userId as UserId),
|
||||
);
|
||||
|
||||
/**
|
||||
* A service for retrieving and managing notifications for end users.
|
||||
*/
|
||||
export class DefaultEndUserNotificationService implements EndUserNotificationService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private apiService: ApiService,
|
||||
private notificationService: NotificationsService,
|
||||
private authService: AuthService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
notifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
|
||||
return this.notificationState(userId).state$.pipe(
|
||||
switchMap(async (notifications) => {
|
||||
if (notifications == null) {
|
||||
await this.fetchNotificationsFromApi(userId);
|
||||
return null;
|
||||
}
|
||||
return notifications;
|
||||
}),
|
||||
filterOutNullish(),
|
||||
map((notifications) =>
|
||||
notifications.map((notification) => new NotificationView(notification)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
unreadNotifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
|
||||
return this.notifications$(userId).pipe(
|
||||
map((notifications) => notifications.filter((notification) => notification.readDate == null)),
|
||||
);
|
||||
});
|
||||
|
||||
async markAsRead(notificationId: NotificationId, userId: UserId): Promise<void> {
|
||||
await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false);
|
||||
await this.notificationState(userId).update((current) => {
|
||||
const notification = current?.find((n) => n.id === notificationId);
|
||||
if (notification) {
|
||||
notification.readDate = new Date();
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
async markAsDeleted(notificationId: NotificationId, userId: UserId): Promise<void> {
|
||||
await this.apiService.send(
|
||||
"DELETE",
|
||||
`/notifications/${notificationId}/delete`,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
await this.notificationState(userId).update((current) => {
|
||||
const notification = current?.find((n) => n.id === notificationId);
|
||||
if (notification) {
|
||||
notification.deletedDate = new Date();
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
async clearState(userId: UserId): Promise<void> {
|
||||
await this.replaceNotificationState(userId, []);
|
||||
}
|
||||
|
||||
async refreshNotifications(userId: UserId) {
|
||||
await this.fetchNotificationsFromApi(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper observable to filter notifications by the notification type and user ids
|
||||
* Returns EMPTY if no user ids are provided
|
||||
* @param userIds
|
||||
* @private
|
||||
*/
|
||||
private filteredEndUserNotifications$(userIds: UserId[]) {
|
||||
if (userIds.length == 0) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this.notificationService.notifications$.pipe(
|
||||
filter(
|
||||
([{ type }, userId]) =>
|
||||
(type === NotificationType.Notification ||
|
||||
type === NotificationType.NotificationStatus) &&
|
||||
userIds.includes(userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription to listen for end user push notifications and notification status updates.
|
||||
*/
|
||||
listenForEndUserNotifications(): Subscription {
|
||||
return this.authService.authStatuses$
|
||||
.pipe(
|
||||
getLoggedInUserIds,
|
||||
switchMap((userIds) => this.filteredEndUserNotifications$(userIds)),
|
||||
concatMap(([notification, userId]) =>
|
||||
this.upsertNotification(
|
||||
userId,
|
||||
new NotificationViewData(notification.payload as NotificationViewResponse),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the notifications from the API and updates the local state
|
||||
* @param userId
|
||||
* @private
|
||||
*/
|
||||
private async fetchNotificationsFromApi(userId: UserId): Promise<void> {
|
||||
const res = await this.apiService.send(
|
||||
"GET",
|
||||
`/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
const response = new ListResponse(res, NotificationViewResponse);
|
||||
|
||||
if (response.continuationToken != null) {
|
||||
this.logService.warning(
|
||||
`More notifications available, but not fetched. Consider increasing the page size from ${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
|
||||
const notificationData = response.data.map((n) => new NotificationViewData(n));
|
||||
await this.replaceNotificationState(userId, notificationData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the local state with notifications and returns the updated state
|
||||
* @param userId
|
||||
* @param notifications
|
||||
* @private
|
||||
*/
|
||||
private replaceNotificationState(
|
||||
userId: UserId,
|
||||
notifications: NotificationViewData[],
|
||||
): Promise<NotificationViewData[] | null> {
|
||||
return this.notificationState(userId).update(() => notifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the local state adding the new notification or updates an existing one with the same id
|
||||
* Returns the entire updated notifications state
|
||||
* @param userId
|
||||
* @param notification
|
||||
* @private
|
||||
*/
|
||||
private async upsertNotification(
|
||||
userId: UserId,
|
||||
notification: NotificationViewData,
|
||||
): Promise<NotificationViewData[] | null> {
|
||||
return this.notificationState(userId).update((current) => {
|
||||
current ??= [];
|
||||
|
||||
const existingIndex = current.findIndex((n) => n.id === notification.id);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
current.push(notification);
|
||||
} else {
|
||||
current[existingIndex] = notification;
|
||||
}
|
||||
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local state for notifications
|
||||
* @param userId
|
||||
* @private
|
||||
*/
|
||||
private notificationState(userId: UserId) {
|
||||
return this.stateProvider.getUser(userId, NOTIFICATIONS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { NOTIFICATION_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { NotificationViewData } from "../models";
|
||||
|
||||
export const NOTIFICATIONS = UserKeyDefinition.array<NotificationViewData>(
|
||||
NOTIFICATION_DISK,
|
||||
"notifications",
|
||||
{
|
||||
deserializer: (notification: Jsonify<NotificationViewData>) =>
|
||||
NotificationViewData.fromJSON(notification),
|
||||
clearOn: ["logout", "lock"],
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user