mirror of
https://github.com/bitwarden/browser
synced 2026-02-12 14:34:02 +00:00
Merge branch 'main' into auth/pm-18458/create-change-existing-password-component
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
@@ -8,10 +8,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
ActiveUserState,
|
||||
StateProvider,
|
||||
COLLECTION_DATA,
|
||||
DeriveDefinition,
|
||||
DerivedState,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -84,6 +84,7 @@ export class DefaultCollectionService implements CollectionService {
|
||||
switchMap(([userId, collectionData]) =>
|
||||
combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]),
|
||||
),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
this.decryptedCollectionDataState = this.stateProvider.getDerived(
|
||||
|
||||
@@ -178,7 +178,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
|
||||
const existingUserPublicKeyB64 = Utils.fromBufferToB64(existingUserPublicKey);
|
||||
newKeyPair = [
|
||||
existingUserPublicKeyB64,
|
||||
await this.encryptService.encrypt(existingUserPrivateKey, userKey[0]),
|
||||
await this.encryptService.wrapDecapsulationKey(existingUserPrivateKey, userKey[0]),
|
||||
];
|
||||
} else {
|
||||
newKeyPair = await this.keyService.makeKeyPair(userKey[0]);
|
||||
|
||||
@@ -18,6 +18,11 @@ type BaseCacheOptions<T> = {
|
||||
|
||||
/** An optional injector. Required if the method is called outside of an injection context. */
|
||||
injector?: Injector;
|
||||
|
||||
/**
|
||||
* Optional flag to persist the cached value between navigation events.
|
||||
*/
|
||||
persistNavigation?: boolean;
|
||||
} & (T extends JsonValue ? Deserializer<T> : Required<Deserializer<T>>);
|
||||
|
||||
export type SignalCacheOptions<T> = BaseCacheOptions<T> & {
|
||||
|
||||
@@ -272,6 +272,10 @@ import {
|
||||
} from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||
import {
|
||||
DefaultEndUserNotificationService,
|
||||
EndUserNotificationService,
|
||||
} from "@bitwarden/common/vault/notifications";
|
||||
import {
|
||||
CipherAuthorizationService,
|
||||
DefaultCipherAuthorizationService,
|
||||
@@ -308,12 +312,7 @@ import {
|
||||
UserAsymmetricKeysRegenerationService,
|
||||
} from "@bitwarden/key-management";
|
||||
import { SafeInjectionToken } from "@bitwarden/ui-common";
|
||||
import {
|
||||
DefaultEndUserNotificationService,
|
||||
EndUserNotificationService,
|
||||
NewDeviceVerificationNoticeService,
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
import { NewDeviceVerificationNoticeService, PasswordRepromptService } from "@bitwarden/vault";
|
||||
import {
|
||||
IndividualVaultExportService,
|
||||
IndividualVaultExportServiceAbstraction,
|
||||
@@ -1257,6 +1256,7 @@ const safeProviders: SafeProvider[] = [
|
||||
I18nServiceAbstraction,
|
||||
OrganizationApiServiceAbstraction,
|
||||
SyncService,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -1490,7 +1490,13 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: EndUserNotificationService,
|
||||
useClass: DefaultEndUserNotificationService,
|
||||
deps: [StateProvider, ApiServiceAbstraction, NotificationsService],
|
||||
deps: [
|
||||
StateProvider,
|
||||
ApiServiceAbstraction,
|
||||
NotificationsService,
|
||||
AuthServiceAbstraction,
|
||||
LogService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DeviceTrustToastServiceAbstraction,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Overlay } from "@angular/cdk/overlay";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { openPasswordHistoryDialog } from "@bitwarden/vault";
|
||||
|
||||
import { VaultViewPasswordHistoryService } from "./view-password-history.service";
|
||||
|
||||
jest.mock("@bitwarden/vault", () => ({
|
||||
openPasswordHistoryDialog: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("VaultViewPasswordHistoryService", () => {
|
||||
let service: VaultViewPasswordHistoryService;
|
||||
let dialogService: DialogService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockDialogService = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
VaultViewPasswordHistoryService,
|
||||
{ provide: DialogService, useValue: mockDialogService },
|
||||
Overlay,
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
service = TestBed.inject(VaultViewPasswordHistoryService);
|
||||
dialogService = TestBed.inject(DialogService);
|
||||
});
|
||||
|
||||
describe("viewPasswordHistory", () => {
|
||||
it("calls openPasswordHistoryDialog with the correct parameters", async () => {
|
||||
const mockCipher = { id: "cipher-id" } as CipherView;
|
||||
await service.viewPasswordHistory(mockCipher);
|
||||
expect(openPasswordHistoryDialog).toHaveBeenCalledWith(dialogService, {
|
||||
data: { cipher: mockCipher },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
22
libs/angular/src/services/view-password-history.service.ts
Normal file
22
libs/angular/src/services/view-password-history.service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { openPasswordHistoryDialog } from "@bitwarden/vault";
|
||||
|
||||
/**
|
||||
* This service is used to display the password history dialog in the vault.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VaultViewPasswordHistoryService implements ViewPasswordHistoryService {
|
||||
constructor(private dialogService: DialogService) {}
|
||||
|
||||
/**
|
||||
* Opens the password history dialog for the given cipher ID.
|
||||
* @param cipherId The ID of the cipher to view the password history for.
|
||||
*/
|
||||
async viewPasswordHistory(cipher: CipherView) {
|
||||
openPasswordHistoryDialog(this.dialogService, { data: { cipher } });
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
@Directive()
|
||||
@@ -25,13 +26,14 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
@Input() activeCipherId: string = null;
|
||||
@Output() onCipherClicked = new EventEmitter<CipherView>();
|
||||
@Output() onCipherRightClicked = new EventEmitter<CipherView>();
|
||||
@Output() onAddCipher = new EventEmitter();
|
||||
@Output() onAddCipher = new EventEmitter<CipherType | undefined>();
|
||||
@Output() onAddCipherOptions = new EventEmitter();
|
||||
|
||||
loaded = false;
|
||||
ciphers: CipherView[] = [];
|
||||
deleted = false;
|
||||
organization: Organization;
|
||||
CipherType = CipherType;
|
||||
|
||||
protected searchPending = false;
|
||||
|
||||
@@ -109,8 +111,8 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
this.onCipherRightClicked.emit(cipher);
|
||||
}
|
||||
|
||||
addCipher() {
|
||||
this.onAddCipher.emit();
|
||||
addCipher(type?: CipherType) {
|
||||
this.onAddCipher.emit(type);
|
||||
}
|
||||
|
||||
addCipherOptions() {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[routerLink]="['/']"
|
||||
class="tw-w-[128px] tw-block tw-mb-12 [&>*]:tw-align-top"
|
||||
>
|
||||
<bit-icon [icon]="logo"></bit-icon>
|
||||
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
|
||||
</a>
|
||||
|
||||
<div
|
||||
|
||||
@@ -536,6 +536,10 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
if (storedEmail) {
|
||||
this.formGroup.controls.email.setValue(storedEmail);
|
||||
this.formGroup.controls.rememberEmail.setValue(true);
|
||||
// If we load an email into the form, we need to initialize it for the login process as well
|
||||
// so that other login components can use it.
|
||||
// We do this here as it's possible that a user doesn't edit the email field before submitting.
|
||||
this.loginEmailService.setLoginEmail(storedEmail);
|
||||
} else {
|
||||
this.formGroup.controls.rememberEmail.setValue(false);
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
break;
|
||||
case TwoFactorProviderType.WebAuthn:
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageSubtitle: this.i18nService.t("followTheStepsBelowToFinishLoggingIn"),
|
||||
pageSubtitle: this.i18nService.t("followTheStepsBelowToFinishLoggingInWithSecurityKey"),
|
||||
pageIcon: TwoFactorAuthWebAuthnIcon,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -106,7 +106,9 @@ describe("AuthRequestService", () => {
|
||||
});
|
||||
|
||||
it("should use the master key and hash if they exist", async () => {
|
||||
masterPasswordService.masterKeySubject.next({ encKey: new Uint8Array(64) } as MasterKey);
|
||||
masterPasswordService.masterKeySubject.next(
|
||||
new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
|
||||
);
|
||||
masterPasswordService.masterKeyHashSubject.next("MASTER_KEY_HASH");
|
||||
|
||||
await sut.approveOrDenyAuthRequest(
|
||||
@@ -115,7 +117,7 @@ describe("AuthRequestService", () => {
|
||||
);
|
||||
|
||||
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
{ encKey: new Uint8Array(64) },
|
||||
new SymmetricCryptoKey(new Uint8Array(32)),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AuthRequestPushNotification } from "@bitwarden/common/models/response/n
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
AUTH_REQUEST_DISK_LOCAL,
|
||||
StateProvider,
|
||||
@@ -120,7 +121,10 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
|
||||
keyToEncrypt = await this.keyService.getUserKey();
|
||||
}
|
||||
|
||||
const encryptedKey = await this.encryptService.encapsulateKeyUnsigned(keyToEncrypt, pubKey);
|
||||
const encryptedKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
keyToEncrypt as SymmetricCryptoKey,
|
||||
pubKey,
|
||||
);
|
||||
|
||||
const response = new PasswordlessAuthRequest(
|
||||
encryptedKey.encryptedString,
|
||||
|
||||
@@ -174,7 +174,8 @@ export class PinService implements PinServiceAbstraction {
|
||||
);
|
||||
const kdfConfig = await this.kdfConfigService.getKdfConfig();
|
||||
const pinKey = await this.makePinKey(pin, email, kdfConfig);
|
||||
return await this.encryptService.encrypt(userKey.key, pinKey);
|
||||
|
||||
return await this.encryptService.wrapSymmetricKey(userKey, pinKey);
|
||||
}
|
||||
|
||||
async storePinKeyEncryptedUserKey(
|
||||
|
||||
@@ -170,7 +170,7 @@ describe("PinService", () => {
|
||||
await sut.createPinKeyEncryptedUserKey(mockPin, mockUserKey, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(encryptService.encrypt).toHaveBeenCalledWith(mockUserKey.key, mockPinKey);
|
||||
expect(encryptService.wrapSymmetricKey).toHaveBeenCalledWith(mockUserKey, mockPinKey);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,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",
|
||||
@@ -35,6 +34,11 @@ 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",
|
||||
|
||||
/* Data Insights and Reporting */
|
||||
CriticalApps = "pm-14466-risk-insights-critical-application",
|
||||
EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
@@ -44,10 +48,7 @@ export enum FeatureFlag {
|
||||
|
||||
/* Tools */
|
||||
ItemShare = "item-share",
|
||||
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 +58,8 @@ export enum FeatureFlag {
|
||||
VaultBulkManagementAction = "vault-bulk-management-action",
|
||||
SecurityTasks = "security-tasks",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms",
|
||||
EndUserNotifications = "pm-10609-end-user-notifications",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
@@ -89,17 +92,17 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
|
||||
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
|
||||
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
|
||||
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
|
||||
[FeatureFlag.NotificationRefresh]: FALSE,
|
||||
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
|
||||
/* Tools */
|
||||
[FeatureFlag.ItemShare]: FALSE,
|
||||
/* Data Insights and Reporting */
|
||||
[FeatureFlag.CriticalApps]: FALSE,
|
||||
[FeatureFlag.EnableRiskInsightsNotifications]: FALSE,
|
||||
|
||||
/* Tools */
|
||||
[FeatureFlag.ItemShare]: FALSE,
|
||||
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
|
||||
[FeatureFlag.ExportAttachments]: FALSE,
|
||||
|
||||
/* Vault */
|
||||
[FeatureFlag.PM8851_BrowserOnboardingNudge]: FALSE,
|
||||
@@ -109,6 +112,8 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.SecurityTasks]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
[FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE,
|
||||
[FeatureFlag.EndUserNotifications]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
|
||||
@@ -119,6 +124,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,
|
||||
|
||||
@@ -7,8 +7,50 @@ import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class EncryptService {
|
||||
/**
|
||||
* Encrypts a string or Uint8Array to an EncString
|
||||
* @param plainValue - The value to encrypt
|
||||
* @param key - The key to encrypt the value with
|
||||
*/
|
||||
abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString>;
|
||||
/**
|
||||
* Encrypts a value to a Uint8Array
|
||||
* @param plainValue - The value to encrypt
|
||||
* @param key - The key to encrypt the value with
|
||||
*/
|
||||
abstract encryptToBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncArrayBuffer>;
|
||||
|
||||
/**
|
||||
* Wraps a decapsulation key (Private key) with a symmetric key
|
||||
* @see {@link https://en.wikipedia.org/wiki/Key_wrap}
|
||||
* @param decapsulationKeyPcks8 - The private key in PKCS8 format
|
||||
* @param wrappingKey - The symmetric key to wrap the private key with
|
||||
*/
|
||||
abstract wrapDecapsulationKey(
|
||||
decapsulationKeyPcks8: Uint8Array,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<EncString>;
|
||||
/**
|
||||
* Wraps an encapsulation key (Public key) with a symmetric key
|
||||
* @see {@link https://en.wikipedia.org/wiki/Key_wrap}
|
||||
* @param encapsulationKeySpki - The public key in SPKI format
|
||||
* @param wrappingKey - The symmetric key to wrap the public key with
|
||||
*/
|
||||
abstract wrapEncapsulationKey(
|
||||
encapsulationKeySpki: Uint8Array,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<EncString>;
|
||||
/**
|
||||
* Wraps a symmetric key with another symmetric key
|
||||
* @see {@link https://en.wikipedia.org/wiki/Key_wrap}
|
||||
* @param keyToBeWrapped - The symmetric key to wrap
|
||||
* @param wrappingKey - The symmetric key to wrap the encapsulated key with
|
||||
*/
|
||||
abstract wrapSymmetricKey(
|
||||
keyToBeWrapped: SymmetricCryptoKey,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<EncString>;
|
||||
|
||||
/**
|
||||
* Decrypts an EncString to a string
|
||||
* @param encString - The EncString to decrypt
|
||||
@@ -39,6 +81,7 @@ export abstract class EncryptService {
|
||||
/**
|
||||
* Encapsulates a symmetric key with an asymmetric public key
|
||||
* Note: This does not establish sender authenticity
|
||||
* @see {@link https://en.wikipedia.org/wiki/Key_encapsulation_mechanism}
|
||||
* @param sharedKey - The symmetric key that is to be shared
|
||||
* @param encapsulationKey - The encapsulation key (public key) of the receiver that the key is shared with
|
||||
*/
|
||||
@@ -49,6 +92,7 @@ export abstract class EncryptService {
|
||||
/**
|
||||
* Decapsulates a shared symmetric key with an asymmetric private key
|
||||
* Note: This does not establish sender authenticity
|
||||
* @see {@link https://en.wikipedia.org/wiki/Key_encapsulation_mechanism}
|
||||
* @param encryptedSharedKey - The encrypted shared symmetric key
|
||||
* @param decapsulationKey - The key to decapsulate with (private key)
|
||||
*/
|
||||
@@ -57,13 +101,13 @@ export abstract class EncryptService {
|
||||
decapsulationKey: Uint8Array,
|
||||
): Promise<SymmetricCryptoKey>;
|
||||
/**
|
||||
* @deprecated Use encapsulateKeyUnsigned instead
|
||||
* @deprecated Use @see {@link encapsulateKeyUnsigned} instead
|
||||
* @param data - The data to encrypt
|
||||
* @param publicKey - The public key to encrypt with
|
||||
*/
|
||||
abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString>;
|
||||
/**
|
||||
* @deprecated Use decapsulateKeyUnsigned instead
|
||||
* @deprecated Use @see {@link decapsulateKeyUnsigned} instead
|
||||
* @param data - The ciphertext to decrypt
|
||||
* @param privateKey - The privateKey to decrypt with
|
||||
*/
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -56,22 +56,85 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
let plainBuf: Uint8Array;
|
||||
if (typeof plainValue === "string") {
|
||||
plainBuf = Utils.fromUtf8ToArray(plainValue);
|
||||
return this.encryptUint8Array(Utils.fromUtf8ToArray(plainValue), key);
|
||||
} else {
|
||||
plainBuf = plainValue;
|
||||
return this.encryptUint8Array(plainValue, key);
|
||||
}
|
||||
}
|
||||
|
||||
async wrapDecapsulationKey(
|
||||
decapsulationKeyPkcs8: Uint8Array,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<EncString> {
|
||||
if (decapsulationKeyPkcs8 == null) {
|
||||
throw new Error("No decapsulation key provided for wrapping.");
|
||||
}
|
||||
|
||||
if (wrappingKey == null) {
|
||||
throw new Error("No wrappingKey provided for wrapping.");
|
||||
}
|
||||
|
||||
return await this.encryptUint8Array(decapsulationKeyPkcs8, wrappingKey);
|
||||
}
|
||||
|
||||
async wrapEncapsulationKey(
|
||||
encapsulationKeySpki: Uint8Array,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<EncString> {
|
||||
if (encapsulationKeySpki == null) {
|
||||
throw new Error("No encapsulation key provided for wrapping.");
|
||||
}
|
||||
|
||||
if (wrappingKey == null) {
|
||||
throw new Error("No wrappingKey provided for wrapping.");
|
||||
}
|
||||
|
||||
return await this.encryptUint8Array(encapsulationKeySpki, wrappingKey);
|
||||
}
|
||||
|
||||
async wrapSymmetricKey(
|
||||
keyToBeWrapped: SymmetricCryptoKey,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<EncString> {
|
||||
if (keyToBeWrapped == null) {
|
||||
throw new Error("No keyToBeWrapped provided for wrapping.");
|
||||
}
|
||||
|
||||
if (wrappingKey == null) {
|
||||
throw new Error("No wrappingKey provided for wrapping.");
|
||||
}
|
||||
|
||||
return await this.encryptUint8Array(keyToBeWrapped.key, wrappingKey);
|
||||
}
|
||||
|
||||
private async encryptUint8Array(
|
||||
plainValue: Uint8Array,
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<EncString> {
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
if (this.blockType0) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
if (plainValue == null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const innerKey = key.inner();
|
||||
if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const encObj = await this.aesEncrypt(plainBuf, innerKey);
|
||||
const encObj = await this.aesEncrypt(plainValue, innerKey);
|
||||
const iv = Utils.fromBufferToB64(encObj.iv);
|
||||
const data = Utils.fromBufferToB64(encObj.data);
|
||||
const mac = Utils.fromBufferToB64(encObj.mac);
|
||||
return new EncString(innerKey.type, data, iv, mac);
|
||||
} else if (innerKey.type === EncryptionType.AesCbc256_B64) {
|
||||
const encObj = await this.aesEncryptLegacy(plainBuf, innerKey);
|
||||
const encObj = await this.aesEncryptLegacy(plainValue, innerKey);
|
||||
const iv = Utils.fromBufferToB64(encObj.iv);
|
||||
const data = Utils.fromBufferToB64(encObj.data);
|
||||
return new EncString(innerKey.type, data, iv);
|
||||
@@ -84,7 +147,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 +187,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 +211,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 +254,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 +263,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 +289,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";
|
||||
@@ -28,6 +31,127 @@ describe("EncryptService", () => {
|
||||
encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true);
|
||||
});
|
||||
|
||||
describe("wrapSymmetricKey", () => {
|
||||
it("roundtrip encrypts and decrypts a symmetric key", async () => {
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0));
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
|
||||
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = await encryptService.wrapSymmetricKey(key, wrappingKey);
|
||||
expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0)));
|
||||
});
|
||||
it("fails if key toBeWrapped is null", async () => {
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
await expect(encryptService.wrapSymmetricKey(null, wrappingKey)).rejects.toThrow(
|
||||
"No keyToBeWrapped provided for wrapping.",
|
||||
);
|
||||
});
|
||||
it("fails if wrapping key is null", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
await expect(encryptService.wrapSymmetricKey(key, null)).rejects.toThrow(
|
||||
"No wrappingKey provided for wrapping.",
|
||||
);
|
||||
});
|
||||
it("fails if type 0 key is provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: mock32Key.key,
|
||||
});
|
||||
|
||||
await expect(encryptService.wrapSymmetricKey(mock32Key, mock32Key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapDecapsulationKey", () => {
|
||||
it("roundtrip encrypts and decrypts a decapsulation key", async () => {
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0));
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
|
||||
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = await encryptService.wrapDecapsulationKey(
|
||||
makeStaticByteArray(64),
|
||||
wrappingKey,
|
||||
);
|
||||
expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0)));
|
||||
});
|
||||
it("fails if decapsulation key is null", async () => {
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
await expect(encryptService.wrapDecapsulationKey(null, wrappingKey)).rejects.toThrow(
|
||||
"No decapsulation key provided for wrapping.",
|
||||
);
|
||||
});
|
||||
it("fails if wrapping key is null", async () => {
|
||||
const decapsulationKey = makeStaticByteArray(64);
|
||||
await expect(encryptService.wrapDecapsulationKey(decapsulationKey, null)).rejects.toThrow(
|
||||
"No wrappingKey provided for wrapping.",
|
||||
);
|
||||
});
|
||||
it("throws if type 0 key is provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: mock32Key.key,
|
||||
});
|
||||
|
||||
await expect(
|
||||
encryptService.wrapDecapsulationKey(new Uint8Array(200), mock32Key),
|
||||
).rejects.toThrow("Type 0 encryption is not supported.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapEncapsulationKey", () => {
|
||||
it("roundtrip encrypts and decrypts an encapsulationKey key", async () => {
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0));
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
|
||||
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = await encryptService.wrapEncapsulationKey(
|
||||
makeStaticByteArray(64),
|
||||
wrappingKey,
|
||||
);
|
||||
expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0)));
|
||||
});
|
||||
it("fails if encapsulation key is null", async () => {
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
await expect(encryptService.wrapEncapsulationKey(null, wrappingKey)).rejects.toThrow(
|
||||
"No encapsulation key provided for wrapping.",
|
||||
);
|
||||
});
|
||||
it("fails if wrapping key is null", async () => {
|
||||
const encapsulationKey = makeStaticByteArray(64);
|
||||
await expect(encryptService.wrapEncapsulationKey(encapsulationKey, null)).rejects.toThrow(
|
||||
"No wrappingKey provided for wrapping.",
|
||||
);
|
||||
});
|
||||
it("throws if type 0 key is provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: mock32Key.key,
|
||||
});
|
||||
|
||||
await expect(
|
||||
encryptService.wrapEncapsulationKey(new Uint8Array(200), mock32Key),
|
||||
).rejects.toThrow("Type 0 encryption is not supported.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
const newConfig = mock<ServerConfig>();
|
||||
|
||||
@@ -64,6 +188,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 +274,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 +360,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 +381,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 +399,7 @@ describe("EncryptService", () => {
|
||||
|
||||
expect(cryptoFunctionService.hmac).toBeCalledWith(
|
||||
expect.toEqualBuffer(expectedMacData),
|
||||
key.macKey,
|
||||
(key.inner() as Aes256CbcHmacKey).authenticationKey,
|
||||
"sha256",
|
||||
);
|
||||
|
||||
@@ -450,6 +582,12 @@ describe("EncryptService", () => {
|
||||
expect(actual).toEqual(encString);
|
||||
expect(actual.dataBytes).toEqualBuffer(encryptedData);
|
||||
});
|
||||
|
||||
it("throws if no data was provided", () => {
|
||||
return expect(encryptService.rsaEncrypt(null, new Uint8Array(32))).rejects.toThrow(
|
||||
"No data provided for encryption",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decapsulateKeyUnsigned", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -164,10 +164,10 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
this.encryptService.encapsulateKeyUnsigned(userKey, devicePublicKey),
|
||||
|
||||
// Encrypt devicePublicKey with user key
|
||||
this.encryptService.encrypt(devicePublicKey, userKey),
|
||||
this.encryptService.wrapEncapsulationKey(devicePublicKey, userKey),
|
||||
|
||||
// Encrypt devicePrivateKey with deviceKey
|
||||
this.encryptService.encrypt(devicePrivateKey, deviceKey),
|
||||
this.encryptService.wrapDecapsulationKey(devicePrivateKey, deviceKey),
|
||||
]);
|
||||
|
||||
// Send encrypted keys to server
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -291,7 +290,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
);
|
||||
|
||||
// Re-encrypt the device public key with the new user key
|
||||
const encryptedDevicePublicKey = await this.encryptService.encrypt(
|
||||
const encryptedDevicePublicKey = await this.encryptService.wrapEncapsulationKey(
|
||||
decryptedDevicePublicKey,
|
||||
newUserKey,
|
||||
);
|
||||
|
||||
@@ -346,8 +346,6 @@ describe("deviceTrustService", () => {
|
||||
|
||||
const deviceRsaKeyLength = 2048;
|
||||
let mockDeviceRsaKeyPair: [Uint8Array, Uint8Array];
|
||||
let mockDevicePrivateKey: Uint8Array;
|
||||
let mockDevicePublicKey: Uint8Array;
|
||||
let mockDevicePublicKeyEncryptedUserKey: EncString;
|
||||
let mockUserKeyEncryptedDevicePublicKey: EncString;
|
||||
let mockDeviceKeyEncryptedDevicePrivateKey: EncString;
|
||||
@@ -366,7 +364,8 @@ describe("deviceTrustService", () => {
|
||||
let rsaGenerateKeyPairSpy: jest.SpyInstance;
|
||||
let cryptoSvcGetUserKeySpy: jest.SpyInstance;
|
||||
let cryptoSvcRsaEncryptSpy: jest.SpyInstance;
|
||||
let encryptServiceEncryptSpy: jest.SpyInstance;
|
||||
let encryptServiceWrapDecapsulationKeySpy: jest.SpyInstance;
|
||||
let encryptServiceWrapEncapsulationKeySpy: jest.SpyInstance;
|
||||
let appIdServiceGetAppIdSpy: jest.SpyInstance;
|
||||
let devicesApiServiceUpdateTrustedDeviceKeysSpy: jest.SpyInstance;
|
||||
|
||||
@@ -384,9 +383,6 @@ describe("deviceTrustService", () => {
|
||||
new Uint8Array(deviceRsaKeyLength),
|
||||
];
|
||||
|
||||
mockDevicePublicKey = mockDeviceRsaKeyPair[0];
|
||||
mockDevicePrivateKey = mockDeviceRsaKeyPair[1];
|
||||
|
||||
mockDevicePublicKeyEncryptedUserKey = new EncString(
|
||||
EncryptionType.Rsa2048_OaepSha1_B64,
|
||||
"mockDevicePublicKeyEncryptedUserKey",
|
||||
@@ -419,13 +415,17 @@ describe("deviceTrustService", () => {
|
||||
.spyOn(encryptService, "encapsulateKeyUnsigned")
|
||||
.mockResolvedValue(mockDevicePublicKeyEncryptedUserKey);
|
||||
|
||||
encryptServiceEncryptSpy = jest
|
||||
.spyOn(encryptService, "encrypt")
|
||||
encryptServiceWrapEncapsulationKeySpy = jest
|
||||
.spyOn(encryptService, "wrapEncapsulationKey")
|
||||
.mockImplementation((plainValue, key) => {
|
||||
if (plainValue === mockDevicePublicKey && key === mockUserKey) {
|
||||
if (plainValue instanceof Uint8Array && key instanceof SymmetricCryptoKey) {
|
||||
return Promise.resolve(mockUserKeyEncryptedDevicePublicKey);
|
||||
}
|
||||
if (plainValue === mockDevicePrivateKey && key === mockDeviceKey) {
|
||||
});
|
||||
encryptServiceWrapDecapsulationKeySpy = jest
|
||||
.spyOn(encryptService, "wrapDecapsulationKey")
|
||||
.mockImplementation((plainValue, key) => {
|
||||
if (plainValue instanceof Uint8Array && key instanceof SymmetricCryptoKey) {
|
||||
return Promise.resolve(mockDeviceKeyEncryptedDevicePrivateKey);
|
||||
}
|
||||
});
|
||||
@@ -452,7 +452,8 @@ describe("deviceTrustService", () => {
|
||||
const userKey = cryptoSvcRsaEncryptSpy.mock.calls[0][0];
|
||||
expect(userKey.key.byteLength).toBe(64);
|
||||
|
||||
expect(encryptServiceEncryptSpy).toHaveBeenCalledTimes(2);
|
||||
expect(encryptServiceWrapDecapsulationKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(encryptServiceWrapEncapsulationKeySpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(appIdServiceGetAppIdSpy).toHaveBeenCalledTimes(1);
|
||||
expect(devicesApiServiceUpdateTrustedDeviceKeysSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -508,9 +509,14 @@ describe("deviceTrustService", () => {
|
||||
errorText: "rsaEncrypt error",
|
||||
},
|
||||
{
|
||||
method: "encryptService.encrypt",
|
||||
spy: () => encryptServiceEncryptSpy,
|
||||
errorText: "encryptService.encrypt error",
|
||||
method: "encryptService.wrapEncapsulationKey",
|
||||
spy: () => encryptServiceWrapEncapsulationKeySpy,
|
||||
errorText: "encryptService.wrapEncapsulationKey error",
|
||||
},
|
||||
{
|
||||
method: "encryptService.wrapDecapsulationKey",
|
||||
spy: () => encryptServiceWrapDecapsulationKeySpy,
|
||||
errorText: "encryptService.wrapDecapsulationKey error",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -872,7 +878,7 @@ describe("deviceTrustService", () => {
|
||||
});
|
||||
|
||||
// Mock the reencryption of the device public key with the new user key
|
||||
encryptService.encrypt.mockImplementationOnce((plainValue, key) => {
|
||||
encryptService.wrapEncapsulationKey.mockImplementationOnce((plainValue, key) => {
|
||||
expect(plainValue).toBeInstanceOf(Uint8Array);
|
||||
expect(new Uint8Array(plainValue as Uint8Array)[0]).toBe(FakeDecryptedPublicKeyMarker);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,7 +479,7 @@ describe("SendService", () => {
|
||||
beforeEach(() => {
|
||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
|
||||
encryptedKey = new EncString("Re-encrypted Send Key");
|
||||
encryptService.encrypt.mockResolvedValue(encryptedKey);
|
||||
encryptService.wrapSymmetricKey.mockResolvedValue(encryptedKey);
|
||||
});
|
||||
|
||||
it("returns re-encrypted user sends", async () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
model: SendView,
|
||||
file: File | ArrayBuffer,
|
||||
password: string,
|
||||
key?: SymmetricCryptoKey,
|
||||
userKey?: SymmetricCryptoKey,
|
||||
): Promise<[Send, EncArrayBuffer]> {
|
||||
let fileData: EncArrayBuffer = null;
|
||||
const send = new Send();
|
||||
@@ -62,15 +62,19 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
send.deletionDate = model.deletionDate;
|
||||
send.expirationDate = model.expirationDate;
|
||||
if (model.key == null) {
|
||||
// Sends use a seed, stored in the URL fragment. This seed is used to derive the key that is used for encryption.
|
||||
const key = await this.keyGenerationService.createKeyWithPurpose(
|
||||
128,
|
||||
this.sendKeyPurpose,
|
||||
this.sendKeySalt,
|
||||
);
|
||||
// key.material is the seed that can be used to re-derive the key
|
||||
model.key = key.material;
|
||||
model.cryptoKey = key.derivedKey;
|
||||
}
|
||||
if (password != null) {
|
||||
// Note: Despite being called key, the passwordKey is not used for encryption.
|
||||
// It is used as a static proof that the client knows the password, and has the encryption key.
|
||||
const passwordKey = await this.keyGenerationService.deriveKeyFromPassword(
|
||||
password,
|
||||
model.key,
|
||||
@@ -78,10 +82,11 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
);
|
||||
send.password = passwordKey.keyB64;
|
||||
}
|
||||
if (key == null) {
|
||||
key = await this.keyService.getUserKey();
|
||||
if (userKey == null) {
|
||||
userKey = await this.keyService.getUserKey();
|
||||
}
|
||||
send.key = await this.encryptService.encrypt(model.key, key);
|
||||
// Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey
|
||||
send.key = await this.encryptService.encrypt(model.key, userKey);
|
||||
send.name = await this.encryptService.encrypt(model.name, model.cryptoKey);
|
||||
send.notes = await this.encryptService.encrypt(model.notes, model.cryptoKey);
|
||||
if (send.type === SendType.Text) {
|
||||
@@ -287,8 +292,10 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
) {
|
||||
const requests = await Promise.all(
|
||||
sends.map(async (send) => {
|
||||
const sendKey = await this.encryptService.decryptToBytes(send.key, originalUserKey);
|
||||
send.key = await this.encryptService.encrypt(sendKey, rotateUserKey);
|
||||
const sendKey = new SymmetricCryptoKey(
|
||||
await this.encryptService.decryptToBytes(send.key, originalUserKey),
|
||||
);
|
||||
send.key = await this.encryptService.wrapSymmetricKey(sendKey, rotateUserKey);
|
||||
return new SendWithIdRequest(send);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { NotificationView } from "../models";
|
||||
|
||||
@@ -25,18 +25,23 @@ export abstract class EndUserNotificationService {
|
||||
* @param notificationId
|
||||
* @param userId
|
||||
*/
|
||||
abstract markAsRead(notificationId: any, userId: UserId): Promise<void>;
|
||||
abstract markAsRead(notificationId: NotificationId, userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Mark a notification as deleted.
|
||||
* @param notificationId
|
||||
* @param userId
|
||||
*/
|
||||
abstract markAsDeleted(notificationId: any, userId: UserId): Promise<void>;
|
||||
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";
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { NotificationId } from "@bitwarden/common/types/guid";
|
||||
import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { NotificationViewResponse } from "./notification-view.response";
|
||||
|
||||
@@ -10,6 +10,7 @@ export class NotificationViewData {
|
||||
title: string;
|
||||
body: string;
|
||||
date: Date;
|
||||
taskId?: SecurityTaskId;
|
||||
readDate: Date | null;
|
||||
deletedDate: Date | null;
|
||||
|
||||
@@ -19,6 +20,7 @@ export class NotificationViewData {
|
||||
this.title = response.title;
|
||||
this.body = response.body;
|
||||
this.date = response.date;
|
||||
this.taskId = response.taskId;
|
||||
this.readDate = response.readDate;
|
||||
this.deletedDate = response.deletedDate;
|
||||
}
|
||||
@@ -30,6 +32,7 @@ export class NotificationViewData {
|
||||
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,
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { NotificationId } from "@bitwarden/common/types/guid";
|
||||
import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class NotificationViewResponse extends BaseResponse {
|
||||
id: NotificationId;
|
||||
@@ -7,6 +7,7 @@ export class NotificationViewResponse extends BaseResponse {
|
||||
title: string;
|
||||
body: string;
|
||||
date: Date;
|
||||
taskId?: SecurityTaskId;
|
||||
readDate: Date;
|
||||
deletedDate: Date;
|
||||
|
||||
@@ -17,6 +18,7 @@ export class NotificationViewResponse extends BaseResponse {
|
||||
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");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotificationId } from "@bitwarden/common/types/guid";
|
||||
import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class NotificationView {
|
||||
id: NotificationId;
|
||||
@@ -6,6 +6,7 @@ export class NotificationView {
|
||||
title: string;
|
||||
body: string;
|
||||
date: Date;
|
||||
taskId?: SecurityTaskId;
|
||||
readDate: Date | null;
|
||||
deletedDate: Date | null;
|
||||
|
||||
@@ -15,6 +16,7 @@ export class NotificationView {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -280,6 +280,7 @@ describe("Cipher Service", () => {
|
||||
Promise.resolve(new SymmetricCryptoKey(makeStaticByteArray(64)) as CipherKey),
|
||||
);
|
||||
encryptService.encrypt.mockImplementation(encryptText);
|
||||
encryptService.wrapSymmetricKey.mockResolvedValue(new EncString("Re-encrypted Cipher Key"));
|
||||
|
||||
jest.spyOn(cipherService as any, "getAutofillOnPageLoadDefault").mockResolvedValue(true);
|
||||
});
|
||||
@@ -436,7 +437,7 @@ describe("Cipher Service", () => {
|
||||
|
||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
|
||||
encryptedKey = new EncString("Re-encrypted Cipher Key");
|
||||
encryptService.encrypt.mockResolvedValue(encryptedKey);
|
||||
encryptService.wrapSymmetricKey.mockResolvedValue(encryptedKey);
|
||||
|
||||
keyService.makeCipherKey.mockResolvedValue(
|
||||
new SymmetricCryptoKey(new Uint8Array(32)) as CipherKey,
|
||||
|
||||
@@ -124,12 +124,8 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
* decryption is in progress. The latest decrypted ciphers will be emitted once decryption is complete.
|
||||
*/
|
||||
cipherViews$ = perUserCache$((userId: UserId): Observable<CipherView[] | null> => {
|
||||
return combineLatest([
|
||||
this.encryptedCiphersState(userId).state$,
|
||||
this.localData$(userId),
|
||||
this.keyService.cipherDecryptionKeys$(userId, true),
|
||||
]).pipe(
|
||||
filter(([ciphers, keys]) => ciphers != null && keys != null), // Skip if ciphers haven't been loaded yor synced yet
|
||||
return combineLatest([this.encryptedCiphersState(userId).state$, this.localData$(userId)]).pipe(
|
||||
filter(([ciphers]) => ciphers != null), // Skip if ciphers haven't been loaded yor synced yet
|
||||
switchMap(() => this.getAllDecrypted(userId)),
|
||||
);
|
||||
}, this.clearCipherViewsForUser$);
|
||||
@@ -266,7 +262,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
key,
|
||||
).then(async () => {
|
||||
if (model.key != null) {
|
||||
attachment.key = await this.encryptService.encrypt(model.key.key, key);
|
||||
attachment.key = await this.encryptService.wrapSymmetricKey(model.key, key);
|
||||
}
|
||||
encAttachments.push(attachment);
|
||||
});
|
||||
@@ -1820,8 +1816,8 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
// Then, we have to encrypt the cipher key with the proper key.
|
||||
cipher.key = await this.encryptService.encrypt(
|
||||
decryptedCipherKey.key,
|
||||
cipher.key = await this.encryptService.wrapSymmetricKey(
|
||||
decryptedCipherKey,
|
||||
keyForCipherKeyEncryption,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import { NgClass } from "@angular/common";
|
||||
import { Input, HostBinding, Component, model, computed } from "@angular/core";
|
||||
import { Input, HostBinding, Component, model, computed, input } from "@angular/core";
|
||||
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { debounce, interval } from "rxjs";
|
||||
|
||||
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
|
||||
import { ButtonLikeAbstraction, ButtonType, ButtonSize } from "../shared/button-like.abstraction";
|
||||
|
||||
const focusRing = [
|
||||
"focus-visible:tw-ring-2",
|
||||
@@ -15,6 +13,11 @@ const focusRing = [
|
||||
"focus-visible:tw-z-10",
|
||||
];
|
||||
|
||||
const buttonSizeStyles: Record<ButtonSize, string[]> = {
|
||||
small: ["tw-py-1", "tw-px-3", "tw-text-sm"],
|
||||
default: ["tw-py-1.5", "tw-px-3"],
|
||||
};
|
||||
|
||||
const buttonStyles: Record<ButtonType, string[]> = {
|
||||
primary: [
|
||||
"tw-border-primary-600",
|
||||
@@ -59,8 +62,6 @@ export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
@HostBinding("class") get classList() {
|
||||
return [
|
||||
"tw-font-semibold",
|
||||
"tw-py-1.5",
|
||||
"tw-px-3",
|
||||
"tw-rounded-full",
|
||||
"tw-transition",
|
||||
"tw-border-2",
|
||||
@@ -85,7 +86,8 @@ export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
"disabled:hover:tw-no-underline",
|
||||
]
|
||||
: [],
|
||||
);
|
||||
)
|
||||
.concat(buttonSizeStyles[this.size() || "default"]);
|
||||
}
|
||||
|
||||
protected disabledAttr = computed(() => {
|
||||
@@ -105,7 +107,9 @@ export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false);
|
||||
});
|
||||
|
||||
@Input() buttonType: ButtonType;
|
||||
@Input() buttonType: ButtonType = "secondary";
|
||||
|
||||
size = input<ButtonSize>("default");
|
||||
|
||||
private _block = false;
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ export default {
|
||||
buttonType: "primary",
|
||||
disabled: false,
|
||||
loading: false,
|
||||
size: "default",
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
options: ["small", "default"],
|
||||
control: { type: "radio" },
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
@@ -24,19 +31,19 @@ export const Primary: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<div class="tw-flex tw-gap-4 tw-mb-6">
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block">Button</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-hover">Button:hover</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-focus-visible">Button:focus-visible</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-hover tw-test-focus-visible">Button:hover:focus-visible</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-active">Button:active</button>
|
||||
<div class="tw-flex tw-gap-4 tw-mb-6 tw-items-center">
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Button</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover">Button:hover</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-focus-visible">Button:focus-visible</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover tw-test-focus-visible">Button:hover:focus-visible</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-active">Button:active</button>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4">
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block">Anchor</a>
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-hover">Anchor:hover</a>
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-focus-visible">Anchor:focus-visible</a>
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-hover tw-test-focus-visible">Anchor:hover:focus-visible</a>
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [block]="block" class="tw-test-active">Anchor:active</a>
|
||||
<div class="tw-flex tw-gap-4 tw-items-center">
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Anchor</a>
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover">Anchor:hover</a>
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-focus-visible">Anchor:focus-visible</a>
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover tw-test-focus-visible">Anchor:hover:focus-visible</a>
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-active">Anchor:active</a>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
@@ -59,6 +66,22 @@ export const Danger: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<div class="tw-flex tw-gap-4 tw-mb-6 tw-items-center">
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'primary'" [size]="size" [block]="block">Primary small</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'secondary'" [size]="size" [block]="block">Secondary small</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'danger'" [size]="size" [block]="block">Danger small</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
size: "small",
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, HostBinding, Input } from "@angular/core";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { DomSanitizer, SafeHtml } from "@angular/platform-browser";
|
||||
|
||||
import { Icon, isIcon } from "./icon";
|
||||
|
||||
@Component({
|
||||
selector: "bit-icon",
|
||||
host: {
|
||||
"[attr.aria-hidden]": "!ariaLabel",
|
||||
"[attr.aria-label]": "ariaLabel",
|
||||
"[innerHtml]": "innerHtml",
|
||||
},
|
||||
template: ``,
|
||||
standalone: true,
|
||||
})
|
||||
export class BitIconComponent {
|
||||
innerHtml: SafeHtml | null = null;
|
||||
|
||||
@Input() set icon(icon: Icon) {
|
||||
if (!isIcon(icon)) {
|
||||
this.innerHtml = "";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -21,7 +25,7 @@ export class BitIconComponent {
|
||||
this.innerHtml = this.domSanitizer.bypassSecurityTrustHtml(svg);
|
||||
}
|
||||
|
||||
@HostBinding() innerHtml: SafeHtml;
|
||||
@Input() ariaLabel: string | undefined = undefined;
|
||||
|
||||
constructor(private domSanitizer: DomSanitizer) {}
|
||||
}
|
||||
|
||||
@@ -98,9 +98,19 @@ import * as stories from "./icon.stories";
|
||||
```
|
||||
|
||||
- **HTML:**
|
||||
|
||||
> NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an
|
||||
> `ariaLabel` is explicitly provided to the `<bit-icon>` component
|
||||
|
||||
```html
|
||||
<bit-icon [icon]="Icons.ExampleIcon"></bit-icon>
|
||||
```
|
||||
|
||||
With `ariaLabel`
|
||||
|
||||
```html
|
||||
<bit-icon [icon]="Icons.ExampleIcon" [ariaLabel]="Your custom label text here"></bit-icon>
|
||||
```
|
||||
|
||||
8. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client
|
||||
which supports multiple style modes.
|
||||
|
||||
@@ -26,5 +26,9 @@ export const Default: Story = {
|
||||
mapping: GenericIcons,
|
||||
control: { type: "select" },
|
||||
},
|
||||
ariaLabel: {
|
||||
control: "text",
|
||||
description: "the text used by a screen reader to describe the icon",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, ElementRef, Input, NgZone, OnInit, Optional } from "@angular/core";
|
||||
import { AfterContentChecked, Directive, ElementRef, Input, NgZone, Optional } from "@angular/core";
|
||||
import { take } from "rxjs/operators";
|
||||
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -12,40 +12,72 @@ import { FocusableElement } from "../shared/focusable-element";
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Will focus the element once, when it becomes visible.
|
||||
*
|
||||
* If the component provides the `FocusableElement` interface, the `focus`
|
||||
* method will be called. Otherwise, the native element will be focused.
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[appAutofocus], [bitAutofocus]",
|
||||
})
|
||||
export class AutofocusDirective implements OnInit {
|
||||
export class AutofocusDirective implements AfterContentChecked {
|
||||
@Input() set appAutofocus(condition: boolean | string) {
|
||||
this.autofocus = condition === "" || condition === true;
|
||||
}
|
||||
|
||||
private autofocus: boolean;
|
||||
|
||||
// Track if we have already focused the element.
|
||||
private focused = false;
|
||||
|
||||
constructor(
|
||||
private el: ElementRef,
|
||||
private ngZone: NgZone,
|
||||
@Optional() private focusableElement: FocusableElement,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
if (!Utils.isMobileBrowser && this.autofocus) {
|
||||
if (this.ngZone.isStable) {
|
||||
this.focus();
|
||||
} else {
|
||||
this.ngZone.onStable.pipe(take(1)).subscribe(this.focus.bind(this));
|
||||
}
|
||||
/**
|
||||
* Using AfterContentChecked is a hack to ensure we only focus once. This is because
|
||||
* the element may not be in the DOM, or not be focusable when the directive is
|
||||
* created, and we want to wait until it is.
|
||||
*
|
||||
* Note: This might break in the future since it relies on Angular change detection
|
||||
* to trigger after the element becomes visible.
|
||||
*/
|
||||
ngAfterContentChecked() {
|
||||
// We only want to focus the element on initial render and it's not a mobile browser
|
||||
if (this.focused || !this.autofocus || Utils.isMobileBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.getElement();
|
||||
if (el == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ngZone.isStable) {
|
||||
this.focus();
|
||||
} else {
|
||||
this.ngZone.onStable.pipe(take(1)).subscribe(this.focus.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to focus the element. If successful we set focused to true to prevent further focus
|
||||
* attempts.
|
||||
*/
|
||||
private focus() {
|
||||
const el = this.getElement();
|
||||
|
||||
el.focus();
|
||||
this.focused = el === document.activeElement;
|
||||
}
|
||||
|
||||
private getElement() {
|
||||
if (this.focusableElement) {
|
||||
this.focusableElement.getFocusTarget().focus();
|
||||
} else {
|
||||
this.el.nativeElement.focus();
|
||||
return this.focusableElement.getFocusTarget();
|
||||
}
|
||||
|
||||
return this.el.nativeElement;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement {
|
||||
@Input() autocomplete: string;
|
||||
|
||||
getFocusTarget() {
|
||||
return this.input.nativeElement;
|
||||
return this.input?.nativeElement;
|
||||
}
|
||||
|
||||
onChange(searchText: string) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ModelSignal } from "@angular/core";
|
||||
// @ts-strict-ignore
|
||||
export type ButtonType = "primary" | "secondary" | "danger" | "unstyled";
|
||||
|
||||
export type ButtonSize = "default" | "small";
|
||||
|
||||
export abstract class ButtonLikeAbstraction {
|
||||
loading: ModelSignal<boolean>;
|
||||
disabled: ModelSignal<boolean>;
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
* Used by the `AutofocusDirective` and `A11yGridDirective`.
|
||||
*/
|
||||
export abstract class FocusableElement {
|
||||
getFocusTarget: () => HTMLElement;
|
||||
getFocusTarget: () => HTMLElement | undefined;
|
||||
}
|
||||
|
||||
@@ -7,3 +7,4 @@ export { LockComponentService, UnlockOptions } from "./lock/services/lock-compon
|
||||
export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust-info.component";
|
||||
export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component";
|
||||
export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component";
|
||||
export { RemovePasswordComponent } from "./key-connector/remove-password.component";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<ng-template #loading>
|
||||
<div class="tw-flex tw-items-center tw-justify-center" *ngIf="loading">
|
||||
<ng-template #spinner>
|
||||
<div class="tw-flex tw-items-center tw-justify-center">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-container *ngIf="unlockOptions; else loading">
|
||||
<ng-container *ngIf="unlockOptions && !loading; else spinner">
|
||||
<!-- Biometrics Unlock -->
|
||||
<ng-container *ngIf="activeUnlockOption === UnlockOption.Biometrics">
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angula
|
||||
import { Router } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
interval,
|
||||
mergeMap,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -88,6 +90,7 @@ const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
|
||||
})
|
||||
export class LockComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
protected loading = true;
|
||||
|
||||
activeAccount: Account | null = null;
|
||||
|
||||
@@ -122,6 +125,9 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
|
||||
formGroup: FormGroup | null = null;
|
||||
|
||||
// Browser extension properties:
|
||||
private shouldClosePopout = false;
|
||||
|
||||
// Desktop properties:
|
||||
private deferFocus: boolean | null = null;
|
||||
private biometricAsked = false;
|
||||
@@ -228,22 +234,22 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
private listenForActiveAccountChanges() {
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
switchMap((account) => {
|
||||
return this.handleActiveAccountChange(account);
|
||||
tap((account) => {
|
||||
this.loading = true;
|
||||
this.activeAccount = account;
|
||||
this.resetDataOnActiveAccountChange();
|
||||
}),
|
||||
filter((account): account is Account => account != null),
|
||||
switchMap(async (account) => {
|
||||
await this.handleActiveAccountChange(account);
|
||||
this.loading = false;
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private async handleActiveAccountChange(activeAccount: Account | null) {
|
||||
this.activeAccount = activeAccount;
|
||||
|
||||
this.resetDataOnActiveAccountChange();
|
||||
|
||||
if (activeAccount == null) {
|
||||
return;
|
||||
}
|
||||
private async handleActiveAccountChange(activeAccount: Account) {
|
||||
// this account may be unlocked, prevent any prompts so we can redirect to vault
|
||||
if (await this.keyService.hasUserKeyInMemory(activeAccount.id)) {
|
||||
return;
|
||||
@@ -300,16 +306,12 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
// desktop and extension.
|
||||
if (this.clientType === "desktop") {
|
||||
if (autoPromptBiometrics) {
|
||||
this.loading = false;
|
||||
await this.desktopAutoPromptBiometrics();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.clientType === "browser") {
|
||||
// Firefox closes the popup when unfocused, so this would block all unlock methods
|
||||
if (this.platformUtilsService.getDevice() === DeviceType.FirefoxExtension) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.unlockOptions?.biometrics.enabled &&
|
||||
autoPromptBiometrics &&
|
||||
@@ -323,6 +325,12 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
isNaN(lastProcessReload.getTime()) ||
|
||||
Date.now() - lastProcessReload.getTime() > AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY
|
||||
) {
|
||||
// Firefox extension closes the popup when unfocused during biometric unlock, pop out the window to prevent infinite loop.
|
||||
if (this.platformUtilsService.getDevice() === DeviceType.FirefoxExtension) {
|
||||
await this.lockComponentService.popOutBrowserExtension();
|
||||
this.shouldClosePopout = true;
|
||||
}
|
||||
this.loading = false;
|
||||
await this.unlockViaBiometrics();
|
||||
}
|
||||
}
|
||||
@@ -637,6 +645,13 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
const successRoute = clientTypeToSuccessRouteRecord[this.clientType];
|
||||
await this.router.navigate([successRoute]);
|
||||
}
|
||||
|
||||
if (
|
||||
this.shouldClosePopout &&
|
||||
this.platformUtilsService.getDevice() === DeviceType.FirefoxExtension
|
||||
) {
|
||||
this.lockComponentService.closeBrowserExtensionPopout();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,18 @@ export abstract class LockComponentService {
|
||||
// Extension
|
||||
abstract getBiometricsError(error: any): string | null;
|
||||
abstract getPreviousUrl(): string | null;
|
||||
/**
|
||||
* Opens the current page in a popout window if not already in a popout or the sidebar.
|
||||
* If already in a popout or sidebar, does nothing.
|
||||
* @throws Error if execution context is not a browser extension.
|
||||
*/
|
||||
abstract popOutBrowserExtension(): Promise<void>;
|
||||
/**
|
||||
* Closes the current popout window if in a popout.
|
||||
* If not in a popout, does nothing.
|
||||
* @throws Error if execution context is not a browser extension.
|
||||
*/
|
||||
abstract closeBrowserExtensionPopout(): void;
|
||||
|
||||
// Desktop only
|
||||
abstract isWindowVisible(): Promise<boolean>;
|
||||
|
||||
@@ -232,7 +232,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
}
|
||||
|
||||
const newUserKey = await this.keyGenerationService.createKey(512);
|
||||
return this.buildProtectedSymmetricKey(masterKey, newUserKey.key);
|
||||
return this.buildProtectedSymmetricKey(masterKey, newUserKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,7 +323,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
userKey?: UserKey,
|
||||
): Promise<[UserKey, EncString]> {
|
||||
userKey ||= await this.getUserKey();
|
||||
return await this.buildProtectedSymmetricKey(masterKey, userKey.key);
|
||||
return await this.buildProtectedSymmetricKey(masterKey, userKey);
|
||||
}
|
||||
|
||||
// TODO: move to MasterPasswordService
|
||||
@@ -433,7 +433,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
}
|
||||
|
||||
const newSymKey = await this.keyGenerationService.createKey(512);
|
||||
return this.buildProtectedSymmetricKey(key, newSymKey.key);
|
||||
return this.buildProtectedSymmetricKey(key, newSymKey);
|
||||
}
|
||||
|
||||
private async clearOrgKeys(userId: UserId): Promise<void> {
|
||||
@@ -547,7 +547,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
|
||||
const keyPair = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
|
||||
const publicB64 = Utils.fromBufferToB64(keyPair[0]);
|
||||
const privateEnc = await this.encryptService.encrypt(keyPair[1], key);
|
||||
const privateEnc = await this.encryptService.wrapDecapsulationKey(keyPair[1], key);
|
||||
return [publicB64, privateEnc];
|
||||
}
|
||||
|
||||
@@ -820,18 +820,21 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
|
||||
private async buildProtectedSymmetricKey<T extends SymmetricCryptoKey>(
|
||||
encryptionKey: SymmetricCryptoKey,
|
||||
newSymKey: Uint8Array,
|
||||
newSymKey: SymmetricCryptoKey,
|
||||
): Promise<[T, EncString]> {
|
||||
let protectedSymKey: EncString;
|
||||
if (encryptionKey.key.byteLength === 32) {
|
||||
const stretchedEncryptionKey = await this.keyGenerationService.stretchKey(encryptionKey);
|
||||
protectedSymKey = await this.encryptService.encrypt(newSymKey, stretchedEncryptionKey);
|
||||
protectedSymKey = await this.encryptService.wrapSymmetricKey(
|
||||
newSymKey,
|
||||
stretchedEncryptionKey,
|
||||
);
|
||||
} else if (encryptionKey.key.byteLength === 64) {
|
||||
protectedSymKey = await this.encryptService.encrypt(newSymKey, encryptionKey);
|
||||
protectedSymKey = await this.encryptService.wrapSymmetricKey(newSymKey, encryptionKey);
|
||||
} else {
|
||||
throw new Error("Invalid key size.");
|
||||
}
|
||||
return [new SymmetricCryptoKey(newSymKey) as T, protectedSymKey];
|
||||
return [newSymKey as T, protectedSymKey];
|
||||
}
|
||||
|
||||
userKey$(userId: UserId): Observable<UserKey | null> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as crypto from "crypto";
|
||||
import * as forge from "node-forge";
|
||||
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
CbcDecryptParameters,
|
||||
@@ -172,24 +173,33 @@ export class NodeCryptoFunctionService implements CryptoFunctionService {
|
||||
mac: string | null,
|
||||
key: SymmetricCryptoKey,
|
||||
): CbcDecryptParameters<Uint8Array> {
|
||||
const p = {} as CbcDecryptParameters<Uint8Array>;
|
||||
p.encKey = key.encKey;
|
||||
p.data = Utils.fromB64ToArray(data);
|
||||
p.iv = Utils.fromB64ToArray(iv);
|
||||
const dataBytes = Utils.fromB64ToArray(data);
|
||||
const ivBytes = Utils.fromB64ToArray(iv);
|
||||
const macBytes = mac != null ? Utils.fromB64ToArray(mac) : null;
|
||||
|
||||
const macData = new Uint8Array(p.iv.byteLength + p.data.byteLength);
|
||||
macData.set(new Uint8Array(p.iv), 0);
|
||||
macData.set(new Uint8Array(p.data), p.iv.byteLength);
|
||||
p.macData = macData;
|
||||
const innerKey = key.inner();
|
||||
|
||||
if (key.macKey != null) {
|
||||
p.macKey = key.macKey;
|
||||
if (innerKey.type === EncryptionType.AesCbc256_B64) {
|
||||
return {
|
||||
iv: ivBytes,
|
||||
data: dataBytes,
|
||||
encKey: innerKey.encryptionKey,
|
||||
} as CbcDecryptParameters<Uint8Array>;
|
||||
} else if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const macData = new Uint8Array(ivBytes.byteLength + dataBytes.byteLength);
|
||||
macData.set(new Uint8Array(ivBytes), 0);
|
||||
macData.set(new Uint8Array(dataBytes), ivBytes.byteLength);
|
||||
return {
|
||||
iv: ivBytes,
|
||||
data: dataBytes,
|
||||
mac: macBytes,
|
||||
macData: macData,
|
||||
encKey: innerKey.encryptionKey,
|
||||
macKey: innerKey.authenticationKey,
|
||||
} as CbcDecryptParameters<Uint8Array>;
|
||||
} else {
|
||||
throw new Error("Unsupported encryption type");
|
||||
}
|
||||
if (mac != null) {
|
||||
p.mac = Utils.fromB64ToArray(mac);
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
async aesDecryptFast({
|
||||
|
||||
@@ -39,8 +39,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -184,10 +182,6 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
private onlyManagedCollections = true;
|
||||
private onGenerate$ = new Subject<GenerateRequest>();
|
||||
|
||||
private isExportAttachmentsEnabled$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.ExportAttachments,
|
||||
);
|
||||
|
||||
constructor(
|
||||
protected i18nService: I18nService,
|
||||
protected toastService: ToastService,
|
||||
@@ -202,7 +196,6 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
protected organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
private collectionService: CollectionService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -225,17 +218,14 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
),
|
||||
);
|
||||
|
||||
combineLatest([
|
||||
this.exportForm.controls.vaultSelector.valueChanges,
|
||||
this.isExportAttachmentsEnabled$,
|
||||
])
|
||||
this.exportForm.controls.vaultSelector.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(([value, isExportAttachmentsEnabled]) => {
|
||||
.subscribe((value) => {
|
||||
this.organizationId = value !== "myVault" ? value : undefined;
|
||||
|
||||
this.formatOptions = this.formatOptions.filter((option) => option.value !== "zip");
|
||||
this.exportForm.get("format").setValue("json");
|
||||
if (value === "myVault" && isExportAttachmentsEnabled) {
|
||||
if (value === "myVault") {
|
||||
this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { importProvidersFrom, signal } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import {
|
||||
applicationConfig,
|
||||
@@ -225,6 +226,14 @@ export default {
|
||||
getFeatureFlag: () => Promise.resolve(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
queryParams: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
componentWrapperDecorator(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<ng-container [formGroup]="uriForm">
|
||||
<div class="tw-mb-4 pt-1">
|
||||
<div class="tw-mb-4 tw-pt-1">
|
||||
<div class="tw-flex tw-pt-2" [class.!tw-mb-1]="showMatchDetection">
|
||||
<bit-form-field disableMargin class="tw-flex-1 !tw-pt-0">
|
||||
<bit-label>{{ uriLabel }}</bit-label>
|
||||
|
||||
@@ -83,4 +83,24 @@ describe("AddEditCustomFieldDialogComponent", () => {
|
||||
expect.objectContaining({ value: FieldType.Linked }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not filter out 'Hidden' field type when 'disallowHiddenField' is false", () => {
|
||||
dialogData.disallowHiddenField = false;
|
||||
fixture = TestBed.createComponent(AddEditCustomFieldDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
expect(component.fieldTypeOptions).toContainEqual(
|
||||
expect.objectContaining({ value: FieldType.Hidden }),
|
||||
);
|
||||
});
|
||||
|
||||
it("filers out 'Hidden' field type when 'disallowHiddenField' is true", () => {
|
||||
dialogData.disallowHiddenField = true;
|
||||
fixture = TestBed.createComponent(AddEditCustomFieldDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
expect(component.fieldTypeOptions).not.toContainEqual(
|
||||
expect.objectContaining({ value: FieldType.Hidden }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ export type AddEditCustomFieldDialogData = {
|
||||
cipherType: CipherType;
|
||||
/** When provided, dialog will display edit label variants */
|
||||
editLabelConfig?: { index: number; label: string };
|
||||
disallowHiddenField?: boolean;
|
||||
};
|
||||
|
||||
@Component({
|
||||
@@ -68,6 +69,9 @@ export class AddEditCustomFieldDialogComponent {
|
||||
this.variant = data.editLabelConfig ? "edit" : "add";
|
||||
|
||||
this.fieldTypeOptions = this.fieldTypeOptions.filter((option) => {
|
||||
if (this.data.disallowHiddenField && option.value === FieldType.Hidden) {
|
||||
return false;
|
||||
}
|
||||
// Filter out the Linked field type for Secure Notes
|
||||
if (this.data.cipherType === CipherType.SecureNote) {
|
||||
return option.value !== FieldType.Linked;
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
bitIconButton="bwi-pencil-square"
|
||||
class="tw-self-center tw-mt-2"
|
||||
data-testid="edit-custom-field-button"
|
||||
*ngIf="!isPartialEdit"
|
||||
*ngIf="canEdit(field.value.type)"
|
||||
></button>
|
||||
|
||||
<button
|
||||
@@ -100,7 +100,7 @@
|
||||
[appA11yTitle]="'reorderToggleButton' | i18n: field.value.name"
|
||||
(keydown)="handleKeyDown($event, field.value.name, i)"
|
||||
data-testid="reorder-toggle-button"
|
||||
*ngIf="!isPartialEdit"
|
||||
*ngIf="canEdit(field.value.type)"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ describe("CustomFieldsComponent", () => {
|
||||
announce = jest.fn().mockResolvedValue(null);
|
||||
patchCipher = jest.fn();
|
||||
originalCipherView = new CipherView();
|
||||
config = {} as CipherFormConfig;
|
||||
config = {
|
||||
collections: [],
|
||||
} as CipherFormConfig;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CustomFieldsComponent],
|
||||
@@ -463,5 +465,91 @@ describe("CustomFieldsComponent", () => {
|
||||
// "reorder boolean label to position 4 of 4"
|
||||
expect(announce).toHaveBeenCalledWith("reorderFieldDown boolean label 4 4", "assertive");
|
||||
});
|
||||
|
||||
it("hides reorder buttons when in partial edit mode", () => {
|
||||
originalCipherView.fields = mockFieldViews;
|
||||
config.mode = "partial-edit";
|
||||
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
toggleItems = fixture.debugElement.queryAll(
|
||||
By.css('button[data-testid="reorder-toggle-button"]'),
|
||||
);
|
||||
|
||||
expect(toggleItems).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows all reorders button when in edit mode and viewPassword is true", () => {
|
||||
originalCipherView.fields = mockFieldViews;
|
||||
originalCipherView.viewPassword = true;
|
||||
config.mode = "edit";
|
||||
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const toggleItems = fixture.debugElement.queryAll(
|
||||
By.css('button[data-testid="reorder-toggle-button"]'),
|
||||
);
|
||||
expect(toggleItems).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("shows all reorder buttons except for hidden fields when in edit mode and viewPassword is false", () => {
|
||||
originalCipherView.fields = mockFieldViews;
|
||||
originalCipherView.viewPassword = false;
|
||||
config.mode = "edit";
|
||||
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const toggleItems = fixture.debugElement.queryAll(
|
||||
By.css('button[data-testid="reorder-toggle-button"]'),
|
||||
);
|
||||
|
||||
expect(toggleItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
describe("edit button", () => {
|
||||
it("hides the edit button when in partial-edit mode", () => {
|
||||
originalCipherView.fields = mockFieldViews;
|
||||
config.mode = "partial-edit";
|
||||
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const editButtons = fixture.debugElement.queryAll(
|
||||
By.css('button[data-testid="edit-custom-field-button"]'),
|
||||
);
|
||||
expect(editButtons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows all the edit buttons when in edit mode and viewPassword is true", () => {
|
||||
originalCipherView.fields = mockFieldViews;
|
||||
originalCipherView.viewPassword = true;
|
||||
config.mode = "edit";
|
||||
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const editButtons = fixture.debugElement.queryAll(
|
||||
By.css('button[data-testid="edit-custom-field-button"]'),
|
||||
);
|
||||
expect(editButtons).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("shows all the edit buttons except for hidden fields when in edit mode and viewPassword is false", () => {
|
||||
originalCipherView.fields = mockFieldViews;
|
||||
originalCipherView.viewPassword = false;
|
||||
config.mode = "edit";
|
||||
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const editButtons = fixture.debugElement.queryAll(
|
||||
By.css('button[data-testid="edit-custom-field-button"]'),
|
||||
);
|
||||
expect(editButtons).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,8 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit {
|
||||
/** Emits when a new custom field should be focused */
|
||||
private focusOnNewInput$ = new Subject<void>();
|
||||
|
||||
disallowHiddenField?: boolean;
|
||||
|
||||
destroyed$: DestroyRef;
|
||||
FieldType = FieldType;
|
||||
|
||||
@@ -141,6 +143,13 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit {
|
||||
return this.customFieldsForm.controls.fields as FormArray;
|
||||
}
|
||||
|
||||
canEdit(type: FieldType): boolean {
|
||||
return (
|
||||
!this.isPartialEdit &&
|
||||
(type !== FieldType.Hidden || this.cipherFormContainer.originalCipherView?.viewPassword)
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const linkedFieldsOptionsForCipher = this.getLinkedFieldsOptionsForCipher();
|
||||
const optionsArray = Array.from(linkedFieldsOptionsForCipher?.entries() ?? []);
|
||||
@@ -210,6 +219,7 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit {
|
||||
|
||||
/** Opens the add/edit custom field dialog */
|
||||
openAddEditCustomFieldDialog(editLabelConfig?: AddEditCustomFieldDialogData["editLabelConfig"]) {
|
||||
const { cipherType, mode, originalCipher } = this.cipherFormContainer.config;
|
||||
this.dialogRef = this.dialogService.open<unknown, AddEditCustomFieldDialogData>(
|
||||
AddEditCustomFieldDialogComponent,
|
||||
{
|
||||
@@ -217,8 +227,9 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit {
|
||||
addField: this.addField.bind(this),
|
||||
updateLabel: this.updateLabel.bind(this),
|
||||
removeField: this.removeField.bind(this),
|
||||
cipherType: this.cipherFormContainer.config.cipherType,
|
||||
cipherType,
|
||||
editLabelConfig,
|
||||
disallowHiddenField: mode === "edit" && !originalCipher.viewPassword,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -132,22 +132,22 @@ export class IdentitySectionComponent implements OnInit {
|
||||
|
||||
private initFromExistingCipher(existingIdentity: IdentityView) {
|
||||
this.identityForm.patchValue({
|
||||
firstName: this.initialValues.firstName ?? existingIdentity.firstName,
|
||||
middleName: this.initialValues.middleName ?? existingIdentity.middleName,
|
||||
lastName: this.initialValues.lastName ?? existingIdentity.lastName,
|
||||
company: this.initialValues.company ?? existingIdentity.company,
|
||||
ssn: this.initialValues.ssn ?? existingIdentity.ssn,
|
||||
passportNumber: this.initialValues.passportNumber ?? existingIdentity.passportNumber,
|
||||
licenseNumber: this.initialValues.licenseNumber ?? existingIdentity.licenseNumber,
|
||||
email: this.initialValues.email ?? existingIdentity.email,
|
||||
phone: this.initialValues.phone ?? existingIdentity.phone,
|
||||
address1: this.initialValues.address1 ?? existingIdentity.address1,
|
||||
address2: this.initialValues.address2 ?? existingIdentity.address2,
|
||||
address3: this.initialValues.address3 ?? existingIdentity.address3,
|
||||
city: this.initialValues.city ?? existingIdentity.city,
|
||||
state: this.initialValues.state ?? existingIdentity.state,
|
||||
postalCode: this.initialValues.postalCode ?? existingIdentity.postalCode,
|
||||
country: this.initialValues.country ?? existingIdentity.country,
|
||||
firstName: this.initialValues?.firstName ?? existingIdentity.firstName,
|
||||
middleName: this.initialValues?.middleName ?? existingIdentity.middleName,
|
||||
lastName: this.initialValues?.lastName ?? existingIdentity.lastName,
|
||||
company: this.initialValues?.company ?? existingIdentity.company,
|
||||
ssn: this.initialValues?.ssn ?? existingIdentity.ssn,
|
||||
passportNumber: this.initialValues?.passportNumber ?? existingIdentity.passportNumber,
|
||||
licenseNumber: this.initialValues?.licenseNumber ?? existingIdentity.licenseNumber,
|
||||
email: this.initialValues?.email ?? existingIdentity.email,
|
||||
phone: this.initialValues?.phone ?? existingIdentity.phone,
|
||||
address1: this.initialValues?.address1 ?? existingIdentity.address1,
|
||||
address2: this.initialValues?.address2 ?? existingIdentity.address2,
|
||||
address3: this.initialValues?.address3 ?? existingIdentity.address3,
|
||||
city: this.initialValues?.city ?? existingIdentity.city,
|
||||
state: this.initialValues?.state ?? existingIdentity.state,
|
||||
postalCode: this.initialValues?.postalCode ?? existingIdentity.postalCode,
|
||||
country: this.initialValues?.country ?? existingIdentity.country,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<bit-dialog dialogSize="default" background="alt">
|
||||
<span bitDialogTitle>
|
||||
{{ "attachments" | i18n }}
|
||||
</span>
|
||||
<ng-container bitDialogContent>
|
||||
<app-cipher-attachments
|
||||
*ngIf="cipherId"
|
||||
[cipherId]="cipherId"
|
||||
[submitBtn]="submitBtn"
|
||||
(onUploadSuccess)="uploadSuccessful()"
|
||||
(onRemoveSuccess)="removalSuccessful()"
|
||||
></app-cipher-attachments>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton type="submit" buttonType="primary" [attr.form]="attachmentFormId" #submitBtn>
|
||||
{{ "upload" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
AttachmentsV2Component,
|
||||
AttachmentDialogResult,
|
||||
AttachmentsDialogParams,
|
||||
} from "./attachments-v2.component";
|
||||
|
||||
describe("AttachmentsV2Component", () => {
|
||||
let component: AttachmentsV2Component;
|
||||
let fixture: ComponentFixture<AttachmentsV2Component>;
|
||||
|
||||
const mockCipherId: CipherId = "cipher-id" as CipherId;
|
||||
const mockParams: AttachmentsDialogParams = {
|
||||
cipherId: mockCipherId,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AttachmentsV2Component, NoopAnimationsModule],
|
||||
providers: [
|
||||
{ provide: DIALOG_DATA, useValue: mockParams },
|
||||
{ provide: DialogRef, useValue: mock<DialogRef>() },
|
||||
{ provide: I18nService, useValue: mock<I18nService>() },
|
||||
{ provide: CipherService, useValue: mock<CipherService>() },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ provide: AccountService, useValue: mock<AccountService>() },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AttachmentsV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("initializes without errors and with the correct cipherId", () => {
|
||||
expect(component).toBeTruthy();
|
||||
expect(component.cipherId).toBe(mockParams.cipherId);
|
||||
});
|
||||
|
||||
it("closes the dialog with 'uploaded' result on uploadSuccessful", () => {
|
||||
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
|
||||
|
||||
component.uploadSuccessful();
|
||||
|
||||
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Uploaded });
|
||||
});
|
||||
|
||||
it("closes the dialog with 'removed' result on removalSuccessful", () => {
|
||||
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
|
||||
|
||||
component.removalSuccessful();
|
||||
|
||||
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
DIALOG_DATA,
|
||||
DialogRef,
|
||||
} from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { CipherAttachmentsComponent } from "../../cipher-form/components/attachments/cipher-attachments.component";
|
||||
|
||||
export interface AttachmentsDialogParams {
|
||||
cipherId: CipherId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing the possible results of the attachment dialog.
|
||||
*/
|
||||
export enum AttachmentDialogResult {
|
||||
Uploaded = "uploaded",
|
||||
Removed = "removed",
|
||||
Closed = "closed",
|
||||
}
|
||||
|
||||
export interface AttachmentDialogCloseResult {
|
||||
action: AttachmentDialogResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the attachments dialog.
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-vault-attachments-v2",
|
||||
templateUrl: "attachments-v2.component.html",
|
||||
standalone: true,
|
||||
imports: [ButtonModule, CommonModule, DialogModule, I18nPipe, CipherAttachmentsComponent],
|
||||
})
|
||||
export class AttachmentsV2Component {
|
||||
cipherId: CipherId;
|
||||
attachmentFormId = CipherAttachmentsComponent.attachmentFormID;
|
||||
|
||||
/**
|
||||
* Constructor for AttachmentsV2Component.
|
||||
* @param dialogRef - Reference to the dialog.
|
||||
* @param params - Parameters passed to the dialog.
|
||||
*/
|
||||
constructor(
|
||||
private dialogRef: DialogRef<AttachmentDialogCloseResult>,
|
||||
@Inject(DIALOG_DATA) public params: AttachmentsDialogParams,
|
||||
) {
|
||||
this.cipherId = params.cipherId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the attachments dialog.
|
||||
* @param dialogService - The dialog service.
|
||||
* @param params - The parameters for the dialog.
|
||||
* @returns The dialog reference.
|
||||
*/
|
||||
static open(
|
||||
dialogService: DialogService,
|
||||
params: AttachmentsDialogParams,
|
||||
): DialogRef<AttachmentDialogCloseResult> {
|
||||
return dialogService.open(AttachmentsV2Component, {
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an attachment is successfully uploaded.
|
||||
* Closes the dialog with an 'uploaded' result.
|
||||
*/
|
||||
uploadSuccessful() {
|
||||
this.dialogRef.close({
|
||||
action: AttachmentDialogResult.Uploaded,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an attachment is successfully removed.
|
||||
* Closes the dialog with a 'removed' result.
|
||||
*/
|
||||
removalSuccessful() {
|
||||
this.dialogRef.close({
|
||||
action: AttachmentDialogResult.Removed,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,8 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
const { username, password, totp, fido2Credentials } = this.cipher.login;
|
||||
return username || password || totp || fido2Credentials;
|
||||
|
||||
return username || password || totp || fido2Credentials?.length > 0;
|
||||
}
|
||||
|
||||
get hasAutofill() {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./cipher-view.component";
|
||||
export * from "./attachments/attachments-v2.component";
|
||||
export { CipherAttachmentsComponent } from "../cipher-form/components/attachments/cipher-attachments.component";
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<bit-dialog background="alt">
|
||||
<span bitDialogTitle>
|
||||
{{ "passwordHistory" | i18n }}
|
||||
</span>
|
||||
<ng-container bitDialogContent>
|
||||
<vault-password-history-view [cipher]="cipher" />
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton (click)="close()" buttonType="primary" type="button">
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -0,0 +1,79 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Inject, Component } from "@angular/core";
|
||||
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
DIALOG_DATA,
|
||||
DialogRef,
|
||||
DialogConfig,
|
||||
} from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { PasswordHistoryViewComponent } from "@bitwarden/vault";
|
||||
|
||||
/**
|
||||
* The parameters for the password history dialog.
|
||||
*/
|
||||
export interface ViewPasswordHistoryDialogParams {
|
||||
cipher: CipherView;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dialog component that displays the password history for a cipher.
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-vault-password-history",
|
||||
templateUrl: "password-history.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
ButtonModule,
|
||||
CommonModule,
|
||||
AsyncActionsModule,
|
||||
I18nPipe,
|
||||
DialogModule,
|
||||
PasswordHistoryViewComponent,
|
||||
],
|
||||
})
|
||||
export class PasswordHistoryComponent {
|
||||
/**
|
||||
* The cipher to display the password history for.
|
||||
*/
|
||||
cipher: CipherView;
|
||||
|
||||
/**
|
||||
* The constructor for the password history dialog component.
|
||||
* @param params The parameters passed to the password history dialog.
|
||||
* @param dialogRef The dialog reference - used to close the dialog.
|
||||
**/
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) public params: ViewPasswordHistoryDialogParams,
|
||||
private dialogRef: DialogRef<PasswordHistoryComponent>,
|
||||
) {
|
||||
/**
|
||||
* Set the cipher from the parameters.
|
||||
*/
|
||||
this.cipher = params.cipher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the password history dialog.
|
||||
*/
|
||||
close() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strongly typed wrapper around the dialog service to open the password history dialog.
|
||||
*/
|
||||
export function openPasswordHistoryDialog(
|
||||
dialogService: DialogService,
|
||||
config: DialogConfig<ViewPasswordHistoryDialogParams>,
|
||||
) {
|
||||
return dialogService.open(PasswordHistoryComponent, config);
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { svgIcon } from "@bitwarden/components";
|
||||
|
||||
export const LoginCards = svgIcon`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none">
|
||||
<path class="tw-fill-background-alt4" fill-rule="evenodd" d="M134.152 32.493c0-.69.559-1.25 1.25-1.25h1.493a8.707 8.707 0 0 1 8.707 8.707v79.047a8.707 8.707 0 0 1-8.707 8.707H22.054c-4.81 0-8.707-3.906-8.707-8.713a1.25 1.25 0 1 1 2.5 0 6.212 6.212 0 0 0 6.207 6.213h114.841a6.207 6.207 0 0 0 6.207-6.207V39.95a6.207 6.207 0 0 0-6.207-6.207h-1.493a1.25 1.25 0 0 1-1.25-1.25Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-background-alt4" fill-rule="evenodd" d="M13.105 24.795a6.207 6.207 0 0 0-6.207 6.207v80.538a6.207 6.207 0 0 0 6.207 6.207h114.841a6.207 6.207 0 0 0 6.208-6.207V31.002a6.207 6.207 0 0 0-6.208-6.207H13.106Zm-8.707 6.207a8.707 8.707 0 0 1 8.707-8.707h114.841a8.708 8.708 0 0 1 8.708 8.707v80.538a8.708 8.708 0 0 1-8.708 8.707H13.106a8.707 8.707 0 0 1-8.708-8.707V31.002Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-background-alt4" fill-rule="evenodd" d="M120.964 75.915H20.766c-.819 0-1.483.664-1.483 1.483v8.949c0 .819.664 1.483 1.483 1.483h100.198c.819 0 1.483-.664 1.483-1.483v-8.95c0-.818-.664-1.482-1.483-1.482Zm-100.198-1.5a2.983 2.983 0 0 0-2.983 2.983v8.949a2.983 2.983 0 0 0 2.983 2.983h100.198a2.983 2.983 0 0 0 2.983-2.983v-8.95a2.983 2.983 0 0 0-2.983-2.982H20.766ZM120.964 96.794H20.766c-.819 0-1.483.664-1.483 1.483v8.948c0 .819.664 1.483 1.483 1.483h100.198c.819 0 1.483-.664 1.483-1.483v-8.948c0-.82-.664-1.483-1.483-1.483Zm-100.198-1.5a2.983 2.983 0 0 0-2.983 2.983v8.948a2.983 2.983 0 0 0 2.983 2.983h100.198a2.983 2.983 0 0 0 2.983-2.983v-8.948a2.983 2.983 0 0 0-2.983-2.983H20.766Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M134.152 32.493c0-.69.559-1.25 1.25-1.25h1.493a8.707 8.707 0 0 1 8.707 8.707v79.047a8.707 8.707 0 0 1-8.707 8.707H22.054c-4.81 0-8.707-3.906-8.707-8.713a1.25 1.25 0 1 1 2.5 0 6.212 6.212 0 0 0 6.207 6.213h114.841a6.207 6.207 0 0 0 6.207-6.207V39.95a6.207 6.207 0 0 0-6.207-6.207h-1.493a1.25 1.25 0 0 1-1.25-1.25Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M13.105 24.795a6.207 6.207 0 0 0-6.207 6.207v80.538a6.207 6.207 0 0 0 6.207 6.207h114.841a6.207 6.207 0 0 0 6.208-6.207V31.002a6.207 6.207 0 0 0-6.208-6.207H13.106Zm-8.707 6.207a8.707 8.707 0 0 1 8.707-8.707h114.841a8.708 8.708 0 0 1 8.708 8.707v80.538a8.708 8.708 0 0 1-8.708 8.707H13.106a8.707 8.707 0 0 1-8.708-8.707V31.002Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M120.964 75.915H20.766c-.819 0-1.483.664-1.483 1.483v8.949c0 .819.664 1.483 1.483 1.483h100.198c.819 0 1.483-.664 1.483-1.483v-8.95c0-.818-.664-1.482-1.483-1.482Zm-100.198-1.5a2.983 2.983 0 0 0-2.983 2.983v8.949a2.983 2.983 0 0 0 2.983 2.983h100.198a2.983 2.983 0 0 0 2.983-2.983v-8.95a2.983 2.983 0 0 0-2.983-2.982H20.766ZM120.964 96.794H20.766c-.819 0-1.483.664-1.483 1.483v8.948c0 .819.664 1.483 1.483 1.483h100.198c.819 0 1.483-.664 1.483-1.483v-8.948c0-.82-.664-1.483-1.483-1.483Zm-100.198-1.5a2.983 2.983 0 0 0-2.983 2.983v8.948a2.983 2.983 0 0 0 2.983 2.983h100.198a2.983 2.983 0 0 0 2.983-2.983v-8.948a2.983 2.983 0 0 0-2.983-2.983H20.766Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-accent" fill-rule="evenodd" d="M22.795 81.712a.75.75 0 0 1 .75-.75H87.68a.75.75 0 0 1 0 1.5H23.545a.75.75 0 0 1-.75-.75ZM26.618 100.354a.75.75 0 0 0-1.5 0v1.426l-1.34-.439a.75.75 0 0 0-.466 1.426l1.353.443-.845 1.183a.75.75 0 0 0 1.221.872l.827-1.158.827 1.158a.75.75 0 0 0 1.22-.872l-.845-1.183 1.353-.443a.75.75 0 0 0-.466-1.426l-1.34.439v-1.426Zm9.584 0a.75.75 0 0 0-1.5 0v1.426l-1.339-.439a.75.75 0 0 0-.466 1.426l1.353.443-.845 1.183a.75.75 0 0 0 1.22.872l.827-1.158.827 1.158a.75.75 0 0 0 1.22-.872l-.844-1.183 1.353-.443a.75.75 0 0 0-.467-1.426l-1.339.439v-1.426Zm8.834-.75a.75.75 0 0 1 .75.75v1.426l1.339-.439a.75.75 0 0 1 .467 1.426l-1.353.443.845 1.183a.75.75 0 0 1-1.22.872l-.828-1.159-.827 1.159a.75.75 0 1 1-1.22-.872l.844-1.183-1.353-.443a.75.75 0 0 1 .467-1.426l1.339.439v-1.426a.75.75 0 0 1 .75-.75Zm10.334.75a.75.75 0 0 0-1.5 0v1.426l-1.339-.439a.75.75 0 0 0-.466 1.426l1.353.443-.845 1.183a.75.75 0 1 0 1.22.872l.828-1.159.827 1.159a.75.75 0 0 0 1.22-.872l-.845-1.183 1.353-.443a.75.75 0 0 0-.467-1.426l-1.339.439v-1.426Zm8.835-.75a.75.75 0 0 1 .75.75v1.426l1.338-.439a.75.75 0 0 1 .467 1.426l-1.352.443.845 1.183a.75.75 0 0 1-1.221.872l-.827-1.159-.828 1.159a.75.75 0 0 1-1.22-.872L63 103.21l-1.353-.443a.75.75 0 0 1 .467-1.426l1.34.439v-1.426a.75.75 0 0 1 .75-.75Zm10.334.75a.75.75 0 0 0-1.5 0v1.426l-1.34-.439a.75.75 0 0 0-.466 1.426l1.353.443-.845 1.183a.75.75 0 1 0 1.221.872l.827-1.158.827 1.158a.75.75 0 0 0 1.22-.872l-.844-1.183 1.352-.443a.75.75 0 0 0-.466-1.426l-1.34.439v-1.426ZM53.215 50.395c0-9.566 7.755-17.314 17.313-17.314 9.56 0 17.308 7.749 17.314 17.313 0 5.31-2.399 10.061-6.16 13.236l-.742.626-.418-.876c-1.785-3.736-5.584-6.324-9.994-6.324s-8.214 2.583-9.992 6.323l-.418.878-.743-.627c-3.761-3.175-6.16-7.92-6.16-13.236Zm13.8 5.66a12.594 12.594 0 0 0-7.373 5.801 15.759 15.759 0 0 1-4.927-11.462c0-8.736 7.083-15.813 15.813-15.813s15.808 7.077 15.814 15.814c0 4.51-1.895 8.58-4.928 11.462a12.623 12.623 0 0 0-7.373-5.802 6.659 6.659 0 0 0 3.149-5.66 6.663 6.663 0 0 0-6.662-6.662 6.663 6.663 0 0 0-6.66 6.662 6.659 6.659 0 0 0 3.147 5.66Zm3.513-10.822a5.163 5.163 0 0 0-5.16 5.162 5.163 5.163 0 0 0 5.16 5.16 5.163 5.163 0 0 0 5.162-5.16 5.163 5.163 0 0 0-5.162-5.162Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-background-alt4" fill-rule="evenodd" d="M70.528 34.57c-8.743 0-15.83 7.081-15.83 15.816s7.087 15.817 15.83 15.817c8.744 0 15.83-7.082 15.83-15.817S79.272 34.57 70.528 34.57Zm-17.33 15.816c0-9.564 7.76-17.317 17.33-17.317 9.571 0 17.33 7.753 17.33 17.317 0 9.565-7.759 17.317-17.33 17.317-9.57 0-17.33-7.752-17.33-17.317Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M70.528 34.57c-8.743 0-15.83 7.081-15.83 15.816s7.087 15.817 15.83 15.817c8.744 0 15.83-7.082 15.83-15.817S79.272 34.57 70.528 34.57Zm-17.33 15.816c0-9.564 7.76-17.317 17.33-17.317 9.571 0 17.33 7.753 17.33 17.317 0 9.565-7.759 17.317-17.33 17.317-9.57 0-17.33-7.752-17.33-17.317Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
@@ -3,14 +3,14 @@ import { svgIcon } from "@bitwarden/components";
|
||||
export const SecureDevices = svgIcon`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none">
|
||||
<path class="tw-stroke-art-accent" stroke-width="1.5" d="M122.568 45.774v-3a5.176 5.176 0 0 0-5.176-5.175H34.094a5.176 5.176 0 0 0-5.176 5.176v10.432m67.636 39.392H33.378"/>
|
||||
<path class="tw-stroke-background-alt4" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M53.446 111.924h43.108"/>
|
||||
<path class="tw-stroke-background-alt4" stroke-linejoin="round" stroke-width="1.5" d="M68.118 98.72v13.204M82.96 98.72v13.204"/>
|
||||
<path class="tw-stroke-background-alt4" stroke-width="2.5" d="M127.771 45.775v-3.026c0-5.718-4.635-10.352-10.352-10.352H34.068c-5.718 0-10.352 4.634-10.352 10.352v10.459m72.838 44.595H33.378"/>
|
||||
<path class="tw-stroke-art-primary" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M53.446 111.924h43.108"/>
|
||||
<path class="tw-stroke-art-primary" stroke-linejoin="round" stroke-width="1.5" d="M68.118 98.72v13.204M82.96 98.72v13.204"/>
|
||||
<path class="tw-stroke-art-primary" stroke-width="2.5" d="M127.771 45.775v-3.026c0-5.718-4.635-10.352-10.352-10.352H34.068c-5.718 0-10.352 4.634-10.352 10.352v10.459m72.838 44.595H33.378"/>
|
||||
<path class="tw-fill-art-accent" fill-rule="evenodd" d="M71.835 59.965C72.068 57.392 73.906 55.5 76 55.5s3.932 1.892 4.165 4.465h-8.33Zm-1.005.034c.224-3.015 2.388-5.499 5.17-5.499 2.781 0 4.946 2.484 5.17 5.5 1.6.232 2.83 1.61 2.83 3.275v6.62a3.31 3.31 0 0 1-3.31 3.311h-9.38a3.31 3.31 0 0 1-3.31-3.31v-6.62a3.311 3.311 0 0 1 2.83-3.277Zm.48.966h9.38a2.31 2.31 0 0 1 2.31 2.31v6.62a2.31 2.31 0 0 1-2.31 2.311h-9.38a2.31 2.31 0 0 1-2.31-2.31v-6.62a2.31 2.31 0 0 1 2.31-2.311Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-accent" fill-rule="evenodd" d="M75.448 67.482a1.103 1.103 0 0 1-.551-.956v-.215a1.103 1.103 0 1 1 2.206 0v.215c0 .408-.221.765-.551.956v1.594a.552.552 0 1 1-1.104 0v-1.594Z" clip-rule="evenodd"/>
|
||||
<rect width="30.203" height="56.96" x="3.411" y="52.971" class="tw-stroke-background-alt4" stroke-width="2.5" rx="7.377"/>
|
||||
<rect width="30.203" height="56.96" x="3.411" y="52.971" class="tw-stroke-art-primary" stroke-width="2.5" rx="7.377"/>
|
||||
<path class="tw-stroke-art-accent" stroke-linecap="round" stroke-width="1.725" d="M18.164 58.148h.685"/>
|
||||
<rect width="51.284" height="71.352" x="96.554" y="45.775" class="tw-stroke-background-alt4" stroke-width="2.5" rx="6.901"/>
|
||||
<rect width="51.284" height="71.352" x="96.554" y="45.775" class="tw-stroke-art-primary" stroke-width="2.5" rx="6.901"/>
|
||||
<circle cx="122.568" cy="111.924" r="1.486" class="tw-fill-art-accent"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { svgIcon } from "@bitwarden/components";
|
||||
|
||||
export const SecureUser = svgIcon`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none">
|
||||
<path class="tw-fill-background-alt4" fill-rule="evenodd" d="M96.538 35.676c-.85 0-1.538-.688-1.538-1.538v-.6a1.538 1.538 0 1 1 3.076 0v.6c0 .85-.688 1.538-1.538 1.538ZM103.737 35.676a1.538 1.538 0 0 1-1.538-1.538v-.6a1.538 1.538 0 0 1 3.076 0v.6c0 .85-.688 1.538-1.538 1.538ZM110.935 35.676a1.538 1.538 0 0 1-1.538-1.538v-.6a1.538 1.538 0 0 1 3.076 0v.6c0 .85-.689 1.538-1.538 1.538Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-background-alt4" fill-rule="evenodd" d="M12.708 26.798C7.346 26.798 3 31.145 3 36.508v58.446c0 5.362 4.347 9.71 9.71 9.71h9.567v-2.5h-9.568a7.21 7.21 0 0 1-7.209-7.21V39.451h112.272v5.666a21.936 21.936 0 0 0-8.466-1.688c-12.15 0-22 9.85-22 22 0 9.269 5.732 17.199 13.845 20.439-9.966 2.189-18.447 8.17-23.747 16.296H64.038v2.5H75.91a35.893 35.893 0 0 0-3.913 11.415c-.408 2.511.898 5.791 4.056 5.791h65.572c4.042 0 6.167-2.082 5.564-5.791-2.447-15.065-14.291-27.103-29.529-30.292 8.007-3.29 13.645-11.166 13.645-20.358 0-8.158-4.44-15.278-11.034-19.077v-9.845c0-5.362-4.347-9.71-9.71-9.71H12.708ZM117.771 37.95v-1.444a7.21 7.21 0 0 0-7.21-7.21H12.708a7.21 7.21 0 0 0-7.209 7.21v1.444h112.272Zm-38.837 66.713h-.144a33.454 33.454 0 0 0-4.326 11.816c-.118.732.03 1.578.376 2.164.292.494.65.726 1.213.726h65.572c1.649 0 2.42-.429 2.735-.751.252-.258.576-.819.361-2.139-2.662-16.39-17.328-29.024-35.128-29.024-13.09 0-24.486 6.833-30.659 16.968v.24Zm49.871-39.235c0 10.77-8.73 19.5-19.5 19.5s-19.5-8.73-19.5-19.5 8.73-19.5 19.5-19.5 19.5 8.73 19.5 19.5Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M96.538 35.676c-.85 0-1.538-.688-1.538-1.538v-.6a1.538 1.538 0 1 1 3.076 0v.6c0 .85-.688 1.538-1.538 1.538ZM103.737 35.676a1.538 1.538 0 0 1-1.538-1.538v-.6a1.538 1.538 0 0 1 3.076 0v.6c0 .85-.688 1.538-1.538 1.538ZM110.935 35.676a1.538 1.538 0 0 1-1.538-1.538v-.6a1.538 1.538 0 0 1 3.076 0v.6c0 .85-.689 1.538-1.538 1.538Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M12.708 26.798C7.346 26.798 3 31.145 3 36.508v58.446c0 5.362 4.347 9.71 9.71 9.71h9.567v-2.5h-9.568a7.21 7.21 0 0 1-7.209-7.21V39.451h112.272v5.666a21.936 21.936 0 0 0-8.466-1.688c-12.15 0-22 9.85-22 22 0 9.269 5.732 17.199 13.845 20.439-9.966 2.189-18.447 8.17-23.747 16.296H64.038v2.5H75.91a35.893 35.893 0 0 0-3.913 11.415c-.408 2.511.898 5.791 4.056 5.791h65.572c4.042 0 6.167-2.082 5.564-5.791-2.447-15.065-14.291-27.103-29.529-30.292 8.007-3.29 13.645-11.166 13.645-20.358 0-8.158-4.44-15.278-11.034-19.077v-9.845c0-5.362-4.347-9.71-9.71-9.71H12.708ZM117.771 37.95v-1.444a7.21 7.21 0 0 0-7.21-7.21H12.708a7.21 7.21 0 0 0-7.209 7.21v1.444h112.272Zm-38.837 66.713h-.144a33.454 33.454 0 0 0-4.326 11.816c-.118.732.03 1.578.376 2.164.292.494.65.726 1.213.726h65.572c1.649 0 2.42-.429 2.735-.751.252-.258.576-.819.361-2.139-2.662-16.39-17.328-29.024-35.128-29.024-13.09 0-24.486 6.833-30.659 16.968v.24Zm49.871-39.235c0 10.77-8.73 19.5-19.5 19.5s-19.5-8.73-19.5-19.5 8.73-19.5 19.5-19.5 19.5 8.73 19.5 19.5Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-accent" fill-rule="evenodd" d="M41.932 101.294a3.075 3.075 0 0 1-1.538-2.665v-.6a3.076 3.076 0 1 1 6.152 0v.6a3.075 3.075 0 0 1-1.538 2.665v4.444a1.538 1.538 0 0 1-3.076 0v-4.444Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-accent" fill-rule="evenodd" d="M31.715 80.342c.649-7.236 5.821-12.592 11.755-12.592 5.934 0 11.106 5.356 11.755 12.592h-23.51Zm-2.514.076C29.834 72.08 35.82 65.25 43.47 65.25c7.65 0 13.636 6.83 14.268 15.168 4.533.586 8.034 4.46 8.034 9.152v18.457a9.229 9.229 0 0 1-9.228 9.229H30.396a9.229 9.229 0 0 1-9.228-9.229V89.57c0-4.692 3.501-8.566 8.034-9.152Zm1.195 2.424h26.148a6.728 6.728 0 0 1 6.728 6.728v18.457a6.729 6.729 0 0 1-6.728 6.729H30.396a6.729 6.729 0 0 1-6.728-6.729V89.57a6.728 6.728 0 0 1 6.728-6.728Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
||||
@@ -2,11 +2,11 @@ import { svgIcon } from "@bitwarden/components";
|
||||
|
||||
export const SecurityHandshake = svgIcon`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none">
|
||||
<path class="tw-fill-background-alt4" fill-rule="evenodd" d="M55.54 113.815c1.794.909 3.324 1.383 4.435 1.727l.015.005c.777.24 1.185.374 1.432.507.368.197.8.234 1.196.101.178-.059.34-.136.424-.176l.015-.007c.074-.035.111-.052.145-.066l.043-.016c.091-.023.18-.054.265-.093 1.521-.703 3.915-1.763 5.936-2.033a1.508 1.508 0 1 0-.4-2.989c-2.483.331-5.207 1.549-6.698 2.235a3.936 3.936 0 0 0-.205.071 19.521 19.521 0 0 0-1.259-.415m-5.344 1.149-7.071 12.092 10.526 5.46a1.508 1.508 0 1 1-1.392 2.677l-11.297-5.861a1.438 1.438 0 0 1-.086-.048c-1.192-.72-1.31-2.134-.741-3.096l7.824-13.379.006-.011a2.254 2.254 0 0 1 2.988-.846c1.819 1.003 3.36 1.483 4.587 1.863m30.235 16.5a1.511 1.511 0 0 1 2.097.401c.607.894.98 1.89.98 2.92 0 1.046-.387 2.014-1.104 2.799-.651.739-1.616 1.204-2.55 1.384-.396 1.524-1.314 2.468-2.166 2.927-.453.29-.949.482-1.457.579a4.34 4.34 0 0 1-.753 1.255c-.778 1.183-2.122 1.69-3.26 1.717-.528.684-1.278 1.183-2.103 1.523a1.378 1.378 0 0 1-.207.068c-.479.121-.86.121-1.15.12h-.031c-1.046 0-2.113-.377-2.97-.771a17.903 17.903 0 0 1-2.326-1.316 1.507 1.507 0 0 1-.425-2.09 1.512 1.512 0 0 1 2.092-.425c.54.357 1.225.771 1.921 1.091.729.335 1.319.495 1.708.495.158 0 .232 0 .297-.005a.5.5 0 0 0 .057-.006c.642-.289.817-.592.864-.75a1.509 1.509 0 0 1 2.019-.963.34.34 0 0 0 .035.013l.006.001h.118c.425 0 .74-.204.844-.378.052-.087.112-.169.18-.244.233-.255.341-.534.351-.854a1.508 1.508 0 0 1 1.765-1.439.995.995 0 0 0 .183.016c.228 0 .45-.066.634-.19.052-.035.106-.067.162-.095.135-.068.748-.467.788-1.786a1.506 1.506 0 0 1 1.77-1.439c.056.009.122.015.19.015.46 0 .97-.241 1.148-.447l.03-.033c.233-.253.32-.506.32-.772 0-.291-.106-.707-.459-1.226a1.507 1.507 0 0 1 .402-2.095Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-background-alt4" fill-rule="evenodd" d="M72 112.173c-1.273.841-2.725 2.355-4.115 5.058l-.01.017-.008.017c-.151.275-.17.673.06 1.112.223.352.93.775 2.136.148l.002-.001c1.492-.774 2.61-1.589 3.249-2.055l.27-.196.394-.28h.483c1.04 0 1.622-.069 1.973-.127l1.176-.195.463 1.096c.27.638.738 1.674 1.75 2.722 1.003 1.039 2.605 2.153 5.227 2.823l.379.097 6.936 6.552 9.308-5.228-6.9-11.821-8.164 3.699-9.407-4.142-.055-.019a5.217 5.217 0 0 1-.143-.052 4.902 4.902 0 0 0-1.654-.284c-.92 0-2.086.225-3.35 1.059Zm-1.665-2.515c1.808-1.195 3.566-1.56 5.014-1.56a7.942 7.942 0 0 1 2.62.442h.067l8.555 3.767 7.51-3.403c.622-.295 1.275-.21 1.71-.065a2.269 2.269 0 0 1 1.288 1.069l7.663 13.128.041.102c.447 1.114-.052 2.174-.793 2.728l-.079.059-10.628 5.969-.034.017a2.14 2.14 0 0 1-.611.203c-.186.031-.36.03-.42.03h-.013a2.308 2.308 0 0 1-1.566-.644l-6.743-6.369c-2.938-.818-4.923-2.157-6.267-3.548a11.163 11.163 0 0 1-1.879-2.613c-.241.016-.51.028-.81.034a27.07 27.07 0 0 1-3.503 2.196c-2.178 1.133-4.872.843-6.142-1.31l-.013-.021-.012-.021c-.64-1.17-.807-2.672-.084-4.011 1.569-3.044 3.333-4.993 5.129-6.179ZM57.94 130.702c.891-1.069 2.069-1.933 3.664-1.8l.008.001.01.001c.935.089 1.694.495 2.265.942.338-.144.68-.255 1.02-.329.627-.138 1.35-.175 2.03.051l.01.004.01.004c.977.341 1.645 1.027 2.088 1.686.1.002.202.008.305.017 1.872.175 2.963 1.224 3.556 2.131.99.251 1.717.81 2.216 1.488.572.777.794 1.647.905 2.123.353 1.346.505 3.585.005 5.75l-.012.052-.016.052c-.523 1.697-2.08 2.624-3.747 2.624-1.286 0-2.644-.473-3.66-1.359-1.753-.177-3.254-.773-4.257-1.985a5.169 5.169 0 0 1-3.062-1.144l-.03-.025-.03-.026a4.962 4.962 0 0 1-1.3-1.834 7.301 7.301 0 0 1-1.28-.677c-.767-.513-1.61-1.319-2.011-2.519-.59-1.766-.012-3.553 1.287-5.197l.013-.016.013-.015Zm2.332 1.915c-.918 1.168-.934 1.9-.78 2.359.118.355.396.679.827.968.43.287.91.469 1.226.561l.843.246.203.853c.13.545.38.869.605 1.076.441.341.906.462 1.33.462.1 0 .185-.012.26-.028l1.153-.253.524 1.056c.026.051.053.101.083.151.305.466.902.901 2.221 1.056l.848-.401.748.917c.401.493 1.156.843 1.894.843.531 0 .763-.234.848-.457.37-1.661.23-3.382-.003-4.254l-.007-.025-.005-.025c-.098-.423-.212-.781-.402-1.039-.135-.183-.335-.366-.838-.395l-.93-.054-.37-.855c-.189-.439-.584-1.013-1.482-1.097a.998.998 0 0 0-.402.042l-1.315.417-.533-1.272a2.837 2.837 0 0 0-.41-.7c-.156-.188-.302-.291-.43-.34-.02-.003-.153-.026-.421.033a3.292 3.292 0 0 0-.967.405l-.097.065-1.137.789-.885-1.063a2.803 2.803 0 0 0-.562-.523 1.255 1.255 0 0 0-.565-.228c-.189-.014-.494.023-1.072.71Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-background-alt4" fill-rule="evenodd" d="M65.358 131.532a.754.754 0 0 1-.246 1.038l-.103.062c-.269.163-.767.464-1.28.884-.602.495-1.119 1.074-1.317 1.666-.137.411-.148.939-.064 1.485.083.542.25 1.032.4 1.333a.754.754 0 0 1-1.35.674 6.278 6.278 0 0 1-.543-1.777c-.104-.679-.116-1.471.125-2.191.334-1.002 1.12-1.804 1.79-2.355.605-.496 1.201-.855 1.463-1.012l.087-.053a.756.756 0 0 1 1.038.246ZM68.873 132.736c.19.37.044.825-.326 1.015-.456.234-1.697 1.226-2.23 3.049-.556 1.904-.188 3.039-.072 3.236a.754.754 0 1 1-1.305.76c-.38-.653-.708-2.24-.072-4.418.66-2.26 2.202-3.564 2.988-3.968a.754.754 0 0 1 1.017.326ZM72.056 135.181c.191.37.045.825-.326 1.015-.141.073-.415.314-.724.782a6.358 6.358 0 0 0-.77 1.693c-.187.637-.246 1.364-.225 1.989.01.31.04.58.077.788.034.182.065.271.074.295.002.007.002.008 0 .005a.755.755 0 0 1-1.304.76c-.132-.226-.208-.527-.255-.792a7.079 7.079 0 0 1-.1-1.006c-.025-.737.04-1.631.283-2.461a7.84 7.84 0 0 1 .96-2.102c.365-.553.814-1.046 1.294-1.292a.756.756 0 0 1 1.016.326ZM77.04 136.44l.01.008.027.025.11.101a54.371 54.371 0 0 0 1.782 1.551c1.106.921 2.391 1.905 3.282 2.35a.754.754 0 1 1-.675 1.349c-1.068-.534-2.477-1.628-3.574-2.541a57.15 57.15 0 0 1-1.833-1.595l-.114-.105-.03-.028-.012-.01.514-.553-.514.553a.754.754 0 1 1 1.027-1.105ZM78.495 132.514l.012.011.038.034.15.13a74.33 74.33 0 0 0 2.44 2.015c1.507 1.19 3.281 2.483 4.53 3.075a.753.753 0 1 1-.648 1.362c-1.422-.674-3.32-2.071-4.819-3.254-.76-.6-1.433-1.159-1.918-1.569-.242-.204-.438-.372-.572-.488l-.156-.135-.04-.036-.015-.013.499-.566-.5.566a.756.756 0 0 1 .998-1.132ZM81.422 128.595l.014.012.044.037.17.142c.15.124.366.303.635.522.54.439 1.29 1.038 2.133 1.679 1.707 1.297 3.727 2.715 5.154 3.366a.753.753 0 1 1-.627 1.372c-1.6-.73-3.742-2.247-5.441-3.538a87.058 87.058 0 0 1-2.82-2.242l-.176-.147-.046-.039-.016-.014.488-.575-.488.575a.754.754 0 1 1 .976-1.15ZM134.303 48.211a61.447 61.447 0 0 1-.856 38.597 61.76 61.76 0 0 1-23.725 30.537 1.511 1.511 0 0 1-2.096-.409 1.507 1.507 0 0 1 .408-2.093 58.736 58.736 0 0 0 22.567-29.045 58.431 58.431 0 0 0 .814-36.704 58.696 58.696 0 0 0-21.256-30.008 59.167 59.167 0 0 0-35.015-11.57 59.17 59.17 0 0 0-35.071 11.4A58.707 58.707 0 0 0 18.669 48.82a58.433 58.433 0 0 0 .634 36.708 58.733 58.733 0 0 0 22.424 29.154c.69.469.868 1.407.399 2.095a1.51 1.51 0 0 1-2.098.398 61.752 61.752 0 0 1-23.575-30.652 61.446 61.446 0 0 1-.667-38.6 61.723 61.723 0 0 1 22.502-31.44A62.192 62.192 0 0 1 75.153 4.5a62.188 62.188 0 0 1 36.803 12.161 61.714 61.714 0 0 1 22.348 31.55Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M55.54 113.815c1.794.909 3.324 1.383 4.435 1.727l.015.005c.777.24 1.185.374 1.432.507.368.197.8.234 1.196.101.178-.059.34-.136.424-.176l.015-.007c.074-.035.111-.052.145-.066l.043-.016c.091-.023.18-.054.265-.093 1.521-.703 3.915-1.763 5.936-2.033a1.508 1.508 0 1 0-.4-2.989c-2.483.331-5.207 1.549-6.698 2.235a3.936 3.936 0 0 0-.205.071 19.521 19.521 0 0 0-1.259-.415m-5.344 1.149-7.071 12.092 10.526 5.46a1.508 1.508 0 1 1-1.392 2.677l-11.297-5.861a1.438 1.438 0 0 1-.086-.048c-1.192-.72-1.31-2.134-.741-3.096l7.824-13.379.006-.011a2.254 2.254 0 0 1 2.988-.846c1.819 1.003 3.36 1.483 4.587 1.863m30.235 16.5a1.511 1.511 0 0 1 2.097.401c.607.894.98 1.89.98 2.92 0 1.046-.387 2.014-1.104 2.799-.651.739-1.616 1.204-2.55 1.384-.396 1.524-1.314 2.468-2.166 2.927-.453.29-.949.482-1.457.579a4.34 4.34 0 0 1-.753 1.255c-.778 1.183-2.122 1.69-3.26 1.717-.528.684-1.278 1.183-2.103 1.523a1.378 1.378 0 0 1-.207.068c-.479.121-.86.121-1.15.12h-.031c-1.046 0-2.113-.377-2.97-.771a17.903 17.903 0 0 1-2.326-1.316 1.507 1.507 0 0 1-.425-2.09 1.512 1.512 0 0 1 2.092-.425c.54.357 1.225.771 1.921 1.091.729.335 1.319.495 1.708.495.158 0 .232 0 .297-.005a.5.5 0 0 0 .057-.006c.642-.289.817-.592.864-.75a1.509 1.509 0 0 1 2.019-.963.34.34 0 0 0 .035.013l.006.001h.118c.425 0 .74-.204.844-.378.052-.087.112-.169.18-.244.233-.255.341-.534.351-.854a1.508 1.508 0 0 1 1.765-1.439.995.995 0 0 0 .183.016c.228 0 .45-.066.634-.19.052-.035.106-.067.162-.095.135-.068.748-.467.788-1.786a1.506 1.506 0 0 1 1.77-1.439c.056.009.122.015.19.015.46 0 .97-.241 1.148-.447l.03-.033c.233-.253.32-.506.32-.772 0-.291-.106-.707-.459-1.226a1.507 1.507 0 0 1 .402-2.095Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M72 112.173c-1.273.841-2.725 2.355-4.115 5.058l-.01.017-.008.017c-.151.275-.17.673.06 1.112.223.352.93.775 2.136.148l.002-.001c1.492-.774 2.61-1.589 3.249-2.055l.27-.196.394-.28h.483c1.04 0 1.622-.069 1.973-.127l1.176-.195.463 1.096c.27.638.738 1.674 1.75 2.722 1.003 1.039 2.605 2.153 5.227 2.823l.379.097 6.936 6.552 9.308-5.228-6.9-11.821-8.164 3.699-9.407-4.142-.055-.019a5.217 5.217 0 0 1-.143-.052 4.902 4.902 0 0 0-1.654-.284c-.92 0-2.086.225-3.35 1.059Zm-1.665-2.515c1.808-1.195 3.566-1.56 5.014-1.56a7.942 7.942 0 0 1 2.62.442h.067l8.555 3.767 7.51-3.403c.622-.295 1.275-.21 1.71-.065a2.269 2.269 0 0 1 1.288 1.069l7.663 13.128.041.102c.447 1.114-.052 2.174-.793 2.728l-.079.059-10.628 5.969-.034.017a2.14 2.14 0 0 1-.611.203c-.186.031-.36.03-.42.03h-.013a2.308 2.308 0 0 1-1.566-.644l-6.743-6.369c-2.938-.818-4.923-2.157-6.267-3.548a11.163 11.163 0 0 1-1.879-2.613c-.241.016-.51.028-.81.034a27.07 27.07 0 0 1-3.503 2.196c-2.178 1.133-4.872.843-6.142-1.31l-.013-.021-.012-.021c-.64-1.17-.807-2.672-.084-4.011 1.569-3.044 3.333-4.993 5.129-6.179ZM57.94 130.702c.891-1.069 2.069-1.933 3.664-1.8l.008.001.01.001c.935.089 1.694.495 2.265.942.338-.144.68-.255 1.02-.329.627-.138 1.35-.175 2.03.051l.01.004.01.004c.977.341 1.645 1.027 2.088 1.686.1.002.202.008.305.017 1.872.175 2.963 1.224 3.556 2.131.99.251 1.717.81 2.216 1.488.572.777.794 1.647.905 2.123.353 1.346.505 3.585.005 5.75l-.012.052-.016.052c-.523 1.697-2.08 2.624-3.747 2.624-1.286 0-2.644-.473-3.66-1.359-1.753-.177-3.254-.773-4.257-1.985a5.169 5.169 0 0 1-3.062-1.144l-.03-.025-.03-.026a4.962 4.962 0 0 1-1.3-1.834 7.301 7.301 0 0 1-1.28-.677c-.767-.513-1.61-1.319-2.011-2.519-.59-1.766-.012-3.553 1.287-5.197l.013-.016.013-.015Zm2.332 1.915c-.918 1.168-.934 1.9-.78 2.359.118.355.396.679.827.968.43.287.91.469 1.226.561l.843.246.203.853c.13.545.38.869.605 1.076.441.341.906.462 1.33.462.1 0 .185-.012.26-.028l1.153-.253.524 1.056c.026.051.053.101.083.151.305.466.902.901 2.221 1.056l.848-.401.748.917c.401.493 1.156.843 1.894.843.531 0 .763-.234.848-.457.37-1.661.23-3.382-.003-4.254l-.007-.025-.005-.025c-.098-.423-.212-.781-.402-1.039-.135-.183-.335-.366-.838-.395l-.93-.054-.37-.855c-.189-.439-.584-1.013-1.482-1.097a.998.998 0 0 0-.402.042l-1.315.417-.533-1.272a2.837 2.837 0 0 0-.41-.7c-.156-.188-.302-.291-.43-.34-.02-.003-.153-.026-.421.033a3.292 3.292 0 0 0-.967.405l-.097.065-1.137.789-.885-1.063a2.803 2.803 0 0 0-.562-.523 1.255 1.255 0 0 0-.565-.228c-.189-.014-.494.023-1.072.71Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M65.358 131.532a.754.754 0 0 1-.246 1.038l-.103.062c-.269.163-.767.464-1.28.884-.602.495-1.119 1.074-1.317 1.666-.137.411-.148.939-.064 1.485.083.542.25 1.032.4 1.333a.754.754 0 0 1-1.35.674 6.278 6.278 0 0 1-.543-1.777c-.104-.679-.116-1.471.125-2.191.334-1.002 1.12-1.804 1.79-2.355.605-.496 1.201-.855 1.463-1.012l.087-.053a.756.756 0 0 1 1.038.246ZM68.873 132.736c.19.37.044.825-.326 1.015-.456.234-1.697 1.226-2.23 3.049-.556 1.904-.188 3.039-.072 3.236a.754.754 0 1 1-1.305.76c-.38-.653-.708-2.24-.072-4.418.66-2.26 2.202-3.564 2.988-3.968a.754.754 0 0 1 1.017.326ZM72.056 135.181c.191.37.045.825-.326 1.015-.141.073-.415.314-.724.782a6.358 6.358 0 0 0-.77 1.693c-.187.637-.246 1.364-.225 1.989.01.31.04.58.077.788.034.182.065.271.074.295.002.007.002.008 0 .005a.755.755 0 0 1-1.304.76c-.132-.226-.208-.527-.255-.792a7.079 7.079 0 0 1-.1-1.006c-.025-.737.04-1.631.283-2.461a7.84 7.84 0 0 1 .96-2.102c.365-.553.814-1.046 1.294-1.292a.756.756 0 0 1 1.016.326ZM77.04 136.44l.01.008.027.025.11.101a54.371 54.371 0 0 0 1.782 1.551c1.106.921 2.391 1.905 3.282 2.35a.754.754 0 1 1-.675 1.349c-1.068-.534-2.477-1.628-3.574-2.541a57.15 57.15 0 0 1-1.833-1.595l-.114-.105-.03-.028-.012-.01.514-.553-.514.553a.754.754 0 1 1 1.027-1.105ZM78.495 132.514l.012.011.038.034.15.13a74.33 74.33 0 0 0 2.44 2.015c1.507 1.19 3.281 2.483 4.53 3.075a.753.753 0 1 1-.648 1.362c-1.422-.674-3.32-2.071-4.819-3.254-.76-.6-1.433-1.159-1.918-1.569-.242-.204-.438-.372-.572-.488l-.156-.135-.04-.036-.015-.013.499-.566-.5.566a.756.756 0 0 1 .998-1.132ZM81.422 128.595l.014.012.044.037.17.142c.15.124.366.303.635.522.54.439 1.29 1.038 2.133 1.679 1.707 1.297 3.727 2.715 5.154 3.366a.753.753 0 1 1-.627 1.372c-1.6-.73-3.742-2.247-5.441-3.538a87.058 87.058 0 0 1-2.82-2.242l-.176-.147-.046-.039-.016-.014.488-.575-.488.575a.754.754 0 1 1 .976-1.15ZM134.303 48.211a61.447 61.447 0 0 1-.856 38.597 61.76 61.76 0 0 1-23.725 30.537 1.511 1.511 0 0 1-2.096-.409 1.507 1.507 0 0 1 .408-2.093 58.736 58.736 0 0 0 22.567-29.045 58.431 58.431 0 0 0 .814-36.704 58.696 58.696 0 0 0-21.256-30.008 59.167 59.167 0 0 0-35.015-11.57 59.17 59.17 0 0 0-35.071 11.4A58.707 58.707 0 0 0 18.669 48.82a58.433 58.433 0 0 0 .634 36.708 58.733 58.733 0 0 0 22.424 29.154c.69.469.868 1.407.399 2.095a1.51 1.51 0 0 1-2.098.398 61.752 61.752 0 0 1-23.575-30.652 61.446 61.446 0 0 1-.667-38.6 61.723 61.723 0 0 1 22.502-31.44A62.192 62.192 0 0 1 75.153 4.5a62.188 62.188 0 0 1 36.803 12.161 61.714 61.714 0 0 1 22.348 31.55Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-accent" fill-rule="evenodd" d="M128.53 49.913a55.343 55.343 0 0 1-.771 34.77 55.64 55.64 0 0 1-21.378 27.512.755.755 0 0 1-.844-1.25 54.131 54.131 0 0 0 20.799-26.766 53.85 53.85 0 0 0 .751-33.825 54.094 54.094 0 0 0-19.592-27.653 54.534 54.534 0 0 0-32.27-10.66 54.537 54.537 0 0 0-32.322 10.503 54.1 54.1 0 0 0-19.727 27.558 53.841 53.841 0 0 0 .585 33.828 54.125 54.125 0 0 0 20.667 26.866.754.754 0 1 1-.85 1.247 55.637 55.637 0 0 1-21.243-27.616 55.347 55.347 0 0 1-.6-34.774A55.608 55.608 0 0 1 42.01 21.328a56.05 56.05 0 0 1 33.218-10.796 56.044 56.044 0 0 1 33.164 10.957 55.597 55.597 0 0 1 20.137 28.424Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-background-alt4" fill-rule="evenodd" d="M61.17 48.3c.727-8.197 6.577-14.301 13.33-14.301 6.754 0 12.604 6.104 13.33 14.3a12.392 12.392 0 0 0-.35-.004H61.52c-.117 0-.234.002-.35.005Zm-2.53.343c.6-9.417 7.306-17.144 15.86-17.144 8.555 0 15.26 7.727 15.862 17.144 5.247 1.291 9.138 6.028 9.138 11.673v17.412c0 6.64-5.382 12.022-12.02 12.022H61.52c-6.639 0-12.02-5.383-12.02-12.022V60.316c0-5.646 3.891-10.382 9.138-11.673Zm2.881 2.152H87.48A9.521 9.521 0 0 1 97 60.316v17.412a9.521 9.521 0 0 1-9.52 9.522H61.52a9.521 9.521 0 0 1-9.52-9.522V60.316a9.521 9.521 0 0 1 9.52-9.521Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M61.17 48.3c.727-8.197 6.577-14.301 13.33-14.301 6.754 0 12.604 6.104 13.33 14.3a12.392 12.392 0 0 0-.35-.004H61.52c-.117 0-.234.002-.35.005Zm-2.53.343c.6-9.417 7.306-17.144 15.86-17.144 8.555 0 15.26 7.727 15.862 17.144 5.247 1.291 9.138 6.028 9.138 11.673v17.412c0 6.64-5.382 12.022-12.02 12.022H61.52c-6.639 0-12.02-5.383-12.02-12.022V60.316c0-5.646 3.891-10.382 9.138-11.673Zm2.881 2.152H87.48A9.521 9.521 0 0 1 97 60.316v17.412a9.521 9.521 0 0 1-9.52 9.522H61.52a9.521 9.521 0 0 1-9.52-9.522V60.316a9.521 9.521 0 0 1 9.52-9.521Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-accent" fill-rule="evenodd" d="M72.741 71.859a3.499 3.499 0 0 1-1.74-3.027v-.674a3.5 3.5 0 1 1 7 0v.674c0 1.292-.7 2.42-1.742 3.027v4.956a1.76 1.76 0 1 1-3.518 0V71.86Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
@@ -19,12 +19,13 @@ export { PasswordHistoryViewComponent } from "./components/password-history-view
|
||||
export { NewDeviceVerificationNoticePageOneComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-one.component";
|
||||
export { NewDeviceVerificationNoticePageTwoComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-two.component";
|
||||
export { DecryptionFailureDialogComponent } from "./components/decryption-failure-dialog/decryption-failure-dialog.component";
|
||||
export { openPasswordHistoryDialog } from "./components/password-history/password-history.component";
|
||||
export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.component";
|
||||
export * from "./components/carousel";
|
||||
|
||||
export * as VaultIcons from "./icons";
|
||||
export * from "./notifications";
|
||||
export * from "./services/vault-nudges.service";
|
||||
export * from "./services/custom-nudges-services";
|
||||
|
||||
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
|
||||
export { SshImportPromptService } from "./services/ssh-import-prompt.service";
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./abstractions/end-user-notification.service";
|
||||
export * from "./services/default-end-user-notification.service";
|
||||
@@ -1,200 +0,0 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DefaultEndUserNotificationService } from "@bitwarden/vault";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../common/spec";
|
||||
import { NotificationViewResponse } from "../models";
|
||||
import { NOTIFICATIONS } from "../state/end-user-notification.state";
|
||||
|
||||
describe("End User Notification Center Service", () => {
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
|
||||
const mockApiSend = jest.fn();
|
||||
|
||||
let testBed: TestBed;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockApiSend.mockClear();
|
||||
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||
|
||||
testBed = TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
providers: [
|
||||
DefaultEndUserNotificationService,
|
||||
{
|
||||
provide: StateProvider,
|
||||
useValue: fakeStateProvider,
|
||||
},
|
||||
{
|
||||
provide: ApiService,
|
||||
useValue: {
|
||||
send: mockApiSend,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: NotificationsService,
|
||||
useValue: {
|
||||
notifications$: of(null),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
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 { notifications$ } = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
const result = await firstValueFrom(notifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return notifications API when state is null", async () => {
|
||||
mockApiSend.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any);
|
||||
|
||||
const { notifications$ } = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
const result = await firstValueFrom(notifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true);
|
||||
});
|
||||
|
||||
it("should share the same observable for the same user", async () => {
|
||||
const { notifications$ } = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
const first = notifications$("user-id" as UserId);
|
||||
const second = 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 { unreadNotifications$ } = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
const result = await firstValueFrom(unreadNotifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNotifications", () => {
|
||||
it("should call getNotifications returning notifications from API", async () => {
|
||||
mockApiSend.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.getNotifications("user-id" as UserId);
|
||||
|
||||
expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true);
|
||||
});
|
||||
});
|
||||
it("should update local state when notifications are updated", async () => {
|
||||
mockApiSend.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
|
||||
const mock = fakeStateProvider.singleUser.mockFor(
|
||||
"user-id" as UserId,
|
||||
NOTIFICATIONS,
|
||||
null as any,
|
||||
);
|
||||
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.getNotifications("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,
|
||||
]);
|
||||
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
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 () => {
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.markAsDeleted("notification-id" as NotificationId, "user-id" as UserId);
|
||||
expect(mockApiSend).toHaveBeenCalledWith(
|
||||
"DELETE",
|
||||
"/notifications/notification-id/delete",
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAsRead", () => {
|
||||
it("should send an API request to mark the notification as read", async () => {
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.markAsRead("notification-id" as NotificationId, "user-id" as UserId);
|
||||
expect(mockApiSend).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
"/notifications/notification-id/read",
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { concatMap, filter, map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { NotificationType } from "@bitwarden/common/enums";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { 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";
|
||||
|
||||
/**
|
||||
* A service for retrieving and managing notifications for end users.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class DefaultEndUserNotificationService implements EndUserNotificationService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private apiService: ApiService,
|
||||
private defaultNotifications: NotificationsService,
|
||||
) {
|
||||
this.defaultNotifications.notifications$
|
||||
.pipe(
|
||||
filter(
|
||||
([notification]) =>
|
||||
notification.type === NotificationType.Notification ||
|
||||
notification.type === NotificationType.NotificationStatus,
|
||||
),
|
||||
concatMap(([notification, userId]) =>
|
||||
this.updateNotificationState(userId, [
|
||||
new NotificationViewData(notification.payload as NotificationViewResponse),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
notifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
|
||||
return this.notificationState(userId).state$.pipe(
|
||||
switchMap(async (notifications) => {
|
||||
if (notifications == null) {
|
||||
await this.fetchNotificationsFromApi(userId);
|
||||
}
|
||||
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: any, userId: UserId): Promise<void> {
|
||||
await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false);
|
||||
await this.getNotifications(userId);
|
||||
}
|
||||
|
||||
async markAsDeleted(notificationId: any, userId: UserId): Promise<void> {
|
||||
await this.apiService.send(
|
||||
"DELETE",
|
||||
`/notifications/${notificationId}/delete`,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
await this.getNotifications(userId);
|
||||
}
|
||||
|
||||
async clearState(userId: UserId): Promise<void> {
|
||||
await this.updateNotificationState(userId, []);
|
||||
}
|
||||
|
||||
async getNotifications(userId: UserId) {
|
||||
await this.fetchNotificationsFromApi(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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", null, true, true);
|
||||
const response = new ListResponse(res, NotificationViewResponse);
|
||||
const notificationData = response.data.map((n) => new NotificationView(n));
|
||||
await this.updateNotificationState(userId, notificationData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the local state with notifications and returns the updated state
|
||||
* @param userId
|
||||
* @param notifications
|
||||
* @private
|
||||
*/
|
||||
private updateNotificationState(
|
||||
userId: UserId,
|
||||
notifications: NotificationViewData[],
|
||||
): Promise<NotificationViewData[] | null> {
|
||||
return this.notificationState(userId).update(() => notifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local state for notifications
|
||||
* @param userId
|
||||
* @private
|
||||
*/
|
||||
private notificationState(userId: UserId) {
|
||||
return this.stateProvider.getUser(userId, NOTIFICATIONS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { combineLatest, distinctUntilChanged, map, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||
import { VaultNudgeType } from "../vault-nudges.service";
|
||||
|
||||
/**
|
||||
* Custom Nudge Service used for showing if the user has any existing nudge in the Vault.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class HasNudgeService extends DefaultSingleNudgeService {
|
||||
private accountService = inject(AccountService);
|
||||
|
||||
private nudgeTypes: VaultNudgeType[] = [
|
||||
VaultNudgeType.HasVaultItems,
|
||||
VaultNudgeType.IntroCarouselDismissal,
|
||||
// add additional nudge types here as needed
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns an observable that emits true if any of the provided nudge types are present
|
||||
*/
|
||||
shouldShowNudge$(): Observable<boolean> {
|
||||
return this.accountService.activeAccount$.pipe(
|
||||
switchMap((activeAccount) => {
|
||||
const userId: UserId | undefined = activeAccount?.id;
|
||||
if (!userId) {
|
||||
return of(false);
|
||||
}
|
||||
|
||||
const nudgeObservables: Observable<boolean>[] = this.nudgeTypes.map((nudge) =>
|
||||
super.shouldShowNudge$(nudge, userId),
|
||||
);
|
||||
|
||||
return combineLatest(nudgeObservables).pipe(
|
||||
map((nudgeStates) => nudgeStates.some((state) => state)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./has-items-nudge.service";
|
||||
export * from "./has-nudge.service";
|
||||
|
||||
Reference in New Issue
Block a user