1
0
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:
rr-bw
2025-04-24 22:24:37 -07:00
513 changed files with 8863 additions and 14140 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,4 +6,5 @@ export class OrganizationSponsorshipCreateRequest {
sponsoredEmail: string;
planSponsorshipType: PlanSponsorshipType;
friendlyName: string;
notes?: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { EndUserNotificationService } from "./abstractions/end-user-notification.service";
export { DefaultEndUserNotificationService } from "./services/default-end-user-notification.service";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,5 +6,5 @@
* Used by the `AutofocusDirective` and `A11yGridDirective`.
*/
export abstract class FocusableElement {
getFocusTarget: () => HTMLElement;
getFocusTarget: () => HTMLElement | undefined;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
export * from "./abstractions/end-user-notification.service";
export * from "./services/default-end-user-notification.service";

View File

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

View File

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

View File

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

View File

@@ -1 +1,2 @@
export * from "./has-items-nudge.service";
export * from "./has-nudge.service";