1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-28 02:23:25 +00:00

merge in main

This commit is contained in:
William Martin
2025-05-28 13:45:31 -04:00
1404 changed files with 33141 additions and 18131 deletions

View File

@@ -116,7 +116,7 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
const promises = collections.map(async (c) => {
const view = new CollectionAdminView();
view.id = c.id;
view.name = await this.encryptService.decryptToUtf8(new EncString(c.name), orgKey);
view.name = await this.encryptService.decryptString(new EncString(c.name), orgKey);
view.externalId = c.externalId;
view.organizationId = c.organizationId;
@@ -146,7 +146,7 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
}
const collection = new CollectionRequest();
collection.externalId = model.externalId;
collection.name = (await this.encryptService.encrypt(model.name, key)).encryptedString;
collection.name = (await this.encryptService.encryptString(model.name, key)).encryptedString;
collection.groups = model.groups.map(
(group) =>
new SelectionReadOnlyRequest(group.id, group.readOnly, group.hidePasswords, group.manage),

View File

@@ -120,7 +120,7 @@ const mockStateProvider = () => {
const mockCryptoService = () => {
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
encryptService.decryptToUtf8
encryptService.decryptString
.calledWith(expect.any(EncString), expect.anything())
.mockResolvedValue("DECRYPTED_STRING");

View File

@@ -113,7 +113,7 @@ export class DefaultCollectionService implements CollectionService {
collection.organizationId = model.organizationId;
collection.readOnly = model.readOnly;
collection.externalId = model.externalId;
collection.name = await this.encryptService.encrypt(model.name, key);
collection.name = await this.encryptService.encryptString(model.name, key);
return collection;
}

View File

@@ -46,8 +46,8 @@ describe("DefaultvNextCollectionService", () => {
keyService.orgKeys$.mockReturnValue(cryptoKeys);
// Set up mock decryption
encryptService.decryptToUtf8
.calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey), expect.any(String))
encryptService.decryptString
.calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey))
.mockImplementation((encString, key) =>
Promise.resolve(encString.data.replace("ENC_", "DEC_")),
);
@@ -103,15 +103,14 @@ describe("DefaultvNextCollectionService", () => {
]);
// Assert that the correct org keys were used for each encrypted string
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(
// This should be replaced with decryptString when the platform PR (https://github.com/bitwarden/clients/pull/14544) is merged
expect(encryptService.decryptString).toHaveBeenCalledWith(
expect.objectContaining(new EncString(collection1.name)),
orgKey1,
expect.any(String),
);
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(
expect(encryptService.decryptString).toHaveBeenCalledWith(
expect.objectContaining(new EncString(collection2.name)),
orgKey2,
expect.any(String),
);
});

View File

@@ -113,7 +113,7 @@ export class DefaultvNextCollectionService implements vNextCollectionService {
collection.organizationId = model.organizationId;
collection.readOnly = model.readOnly;
collection.externalId = model.externalId;
collection.name = await this.encryptService.encrypt(model.name, key);
collection.name = await this.encryptService.encryptString(model.name, key);
return collection;
}

View File

@@ -3,6 +3,8 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -50,9 +52,7 @@ export class CollectionsComponent implements OnInit {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.cipherDomain = await this.loadCipher(activeUserId);
this.collectionIds = this.loadCipherCollections();
this.cipher = await this.cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId),
);
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
this.collections = await this.loadCollections();
this.collections.forEach((c) => ((c as any).checked = false));

View File

@@ -4,6 +4,8 @@ import { Directive, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LoginSuccessHandlerService } from "@bitwarden/auth/common";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";

View File

@@ -4,6 +4,8 @@ import { Component, EventEmitter, Output, Input, OnInit, OnDestroy } from "@angu
import { ActivatedRoute } from "@angular/router";
import { Observable, map, Subject, takeUntil } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { SelfHostedEnvConfigDialogComponent } from "@bitwarden/auth/angular";
import {
EnvironmentService,
@@ -57,6 +59,7 @@ export interface EnvironmentSelectorRouteData {
transition("* => void", animate("100ms linear", style({ opacity: 0 }))),
]),
],
standalone: false,
})
export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
@Output() onOpenSelfHostedSettings = new EventEmitter<void>();
@@ -110,16 +113,16 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
/**
* Opens the self-hosted settings dialog when the self-hosted option is selected.
*/
if (
option === Region.SelfHosted &&
(await SelfHostedEnvConfigDialogComponent.open(this.dialogService))
) {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("environmentSaved"),
});
if (option === Region.SelfHosted) {
const dialogResult = await SelfHostedEnvConfigDialogComponent.open(this.dialogService);
if (dialogResult) {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("environmentSaved"),
});
}
// Don't proceed to setEnvironment when the self-hosted dialog is cancelled
return;
}

View File

@@ -5,10 +5,14 @@ import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, of } from "rxjs";
import { filter, first, switchMap, tap } from "rxjs/operators";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
OrganizationUserApiService,
OrganizationUserResetPasswordEnrollmentRequest,
} from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";

View File

@@ -4,6 +4,8 @@ import { Directive, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";

View File

@@ -10,6 +10,7 @@ import { WebAuthnIcon } from "../icons/webauthn.icon";
@Component({
selector: "auth-two-factor-icon",
templateUrl: "./two-factor-icon.component.html",
standalone: false,
})
export class TwoFactorIconComponent {
@Input() provider: any;

View File

@@ -22,6 +22,7 @@ import { KeyService } from "@bitwarden/key-management";
*/
@Directive({
selector: "app-user-verification",
standalone: false,
})
export class UserVerificationComponent implements ControlValueAccessor, OnInit, OnDestroy {
private _invalidSecret = false;

View File

@@ -5,6 +5,8 @@ import { RouterTestingModule } from "@angular/router/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";

View File

@@ -2,6 +2,8 @@ import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";

View File

@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import {
@@ -30,9 +30,7 @@ describe("AuthGuard", () => {
authService.getAuthStatus.mockResolvedValue(authStatus);
const messagingService: MockProxy<MessagingService> = mock<MessagingService>();
const keyConnectorService: MockProxy<KeyConnectorService> = mock<KeyConnectorService>();
keyConnectorService.getConvertAccountRequired.mockResolvedValue(
keyConnectorServiceRequiresAccountConversion,
);
keyConnectorService.convertAccountRequired$ = of(keyConnectorServiceRequiresAccountConversion);
const accountService: MockProxy<AccountService> = mock<AccountService>();
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
accountService.activeAccount$ = activeAccountSubject;

View File

@@ -47,7 +47,7 @@ export const authGuard: CanActivateFn = async (
if (
!routerState.url.includes("remove-password") &&
(await keyConnectorService.getConvertAccountRequired())
(await firstValueFrom(keyConnectorService.convertAccountRequired$))
) {
return router.createUrlTree(["/remove-password"]);
}

View File

@@ -1,5 +1,7 @@
import { merge, Observable, tap } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";

View File

@@ -1,6 +1,8 @@
import { mock, MockProxy } from "jest-mock-extended";
import { EMPTY, of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";

View File

@@ -22,6 +22,8 @@ export type AddAccountCreditDialogParams = {
providerId?: string;
};
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum AddAccountCreditDialogResultType {
Closed = "closed",
Submitted = "submitted",
@@ -46,6 +48,7 @@ type PayPalConfig = {
@Component({
templateUrl: "./add-account-credit-dialog.component.html",
standalone: false,
})
export class AddAccountCreditDialogComponent implements OnInit {
@ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm: ElementRef;

View File

@@ -11,6 +11,7 @@ import { FileDownloadService } from "@bitwarden/common/platform/abstractions/fil
@Component({
selector: "app-invoices",
templateUrl: "./invoices.component.html",
standalone: false,
})
export class InvoicesComponent implements OnInit {
@Input() startWith?: InvoicesResponse;

View File

@@ -30,6 +30,7 @@ const partnerTrustIcon = svgIcon`
<bit-icon [icon]="icon"></bit-icon>
<p class="tw-mt-4">{{ "noInvoicesToList" | i18n }}</p>
</div>`,
standalone: false,
})
export class NoInvoicesComponent {
icon = partnerTrustIcon;

View File

@@ -11,6 +11,7 @@ import { CountryListItem, TaxInformation } from "@bitwarden/common/billing/model
@Component({
selector: "app-manage-tax-information",
templateUrl: "./manage-tax-information.component.html",
standalone: false,
})
export class ManageTaxInformationComponent implements OnInit, OnDestroy {
@Input() startWith: TaxInformation;
@@ -64,12 +65,8 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
};
validate(): boolean {
if (this.formGroup.dirty) {
this.formGroup.markAllAsTouched();
return this.formGroup.valid;
} else {
return this.formGroup.valid;
}
this.formGroup.markAllAsTouched();
return this.formGroup.valid;
}
markAllAsTouched() {

View File

@@ -9,6 +9,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
*/
@Directive({
selector: "[appNotPremium]",
standalone: false,
})
export class NotPremiumDirective implements OnInit {
constructor(

View File

@@ -12,6 +12,7 @@ import { CalloutTypes } from "@bitwarden/components";
@Component({
selector: "app-callout",
templateUrl: "callout.component.html",
standalone: false,
})
export class DeprecatedCalloutComponent implements OnInit {
@Input() type: CalloutTypes = "info";

View File

@@ -18,6 +18,7 @@ import { ModalRef } from "./modal.ref";
@Component({
selector: "app-modal",
template: "<ng-template #modalContent></ng-template>",
standalone: false,
})
export class DynamicModalComponent implements AfterViewInit, OnDestroy {
componentRef: ComponentRef<any>;

View File

@@ -3,6 +3,8 @@
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
@@ -76,9 +78,7 @@ export class ShareComponent implements OnInit, OnDestroy {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId);
this.cipher = await cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain, activeUserId),
);
this.cipher = await this.cipherService.decrypt(cipherDomain, activeUserId);
}
filterCollections() {
@@ -105,9 +105,7 @@ export class ShareComponent implements OnInit, OnDestroy {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId);
const cipherView = await cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain, activeUserId),
);
const cipherView = await this.cipherService.decrypt(cipherDomain, activeUserId);
const orgs = await firstValueFrom(this.organizations$);
const orgName =
orgs.find((o) => o.id === this.organizationId)?.name ?? this.i18nService.t("organization");

View File

@@ -6,6 +6,7 @@ import { Subscription } from "rxjs";
@Directive({
selector: "[appA11yInvalid]",
standalone: false,
})
export class A11yInvalidDirective implements OnDestroy, OnInit {
private sub: Subscription;

View File

@@ -15,6 +15,7 @@ import { ValidationService } from "@bitwarden/common/platform/abstractions/valid
*/
@Directive({
selector: "[appApiAction]",
standalone: false,
})
export class ApiActionDirective implements OnChanges {
@Input() appApiAction: Promise<any>;

View File

@@ -4,6 +4,7 @@ import { Directive, ElementRef, HostListener, OnInit } from "@angular/core";
@Directive({
selector: "[appBoxRow]",
standalone: false,
})
export class BoxRowDirective implements OnInit {
el: HTMLElement = null;

View File

@@ -7,6 +7,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
@Directive({
selector: "[appCopyText]",
standalone: false,
})
export class CopyTextDirective {
constructor(

View File

@@ -4,6 +4,7 @@ import { Directive, ElementRef, HostListener, Input } from "@angular/core";
@Directive({
selector: "[appFallbackSrc]",
standalone: false,
})
export class FallbackSrcDirective {
@Input("appFallbackSrc") appFallbackSrc: string;

View File

@@ -14,6 +14,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
*/
@Directive({
selector: "[appIfFeature]",
standalone: false,
})
export class IfFeatureDirective implements OnInit {
/**

View File

@@ -5,6 +5,7 @@ import { NgControl } from "@angular/forms";
@Directive({
selector: "input[appInputStripSpaces]",
standalone: false,
})
export class InputStripSpacesDirective {
constructor(

View File

@@ -4,6 +4,7 @@ import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core";
@Directive({
selector: "[appInputVerbatim]",
standalone: false,
})
export class InputVerbatimDirective implements OnInit {
@Input() set appInputVerbatim(condition: boolean | string) {

View File

@@ -5,6 +5,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
@Directive({
selector: "[appLaunchClick]",
standalone: false,
})
export class LaunchClickDirective {
constructor(private platformUtilsService: PlatformUtilsService) {}

View File

@@ -2,6 +2,7 @@ import { Directive, HostListener } from "@angular/core";
@Directive({
selector: "[appStopClick]",
standalone: false,
})
export class StopClickDirective {
@HostListener("click", ["$event"]) onClick($event: MouseEvent) {

View File

@@ -2,6 +2,7 @@ import { Directive, HostListener } from "@angular/core";
@Directive({
selector: "[appStopProp]",
standalone: false,
})
export class StopPropDirective {
@HostListener("click", ["$event"]) onClick($event: MouseEvent) {

View File

@@ -11,6 +11,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
multi: true,
},
],
standalone: false,
})
export class TrueFalseValueDirective implements ControlValueAccessor {
@Input() trueValue: boolean | string = true;

View File

@@ -7,7 +7,10 @@ import { ColorPasswordPipe } from "./color-password.pipe";
/*
An updated pipe that extends ColourPasswordPipe to include a character count
*/
@Pipe({ name: "colorPasswordCount" })
@Pipe({
name: "colorPasswordCount",
standalone: false,
})
export class ColorPasswordCountPipe extends ColorPasswordPipe {
transform(password: string) {
const template = (character: string, type: string, index: number) =>

View File

@@ -6,7 +6,10 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
An updated pipe that sanitizes HTML, highlights numbers and special characters (in different colors each)
and handles Unicode / Emoji characters correctly.
*/
@Pipe({ name: "colorPassword" })
@Pipe({
name: "colorPassword",
standalone: false,
})
export class ColorPasswordPipe implements PipeTransform {
transform(password: string) {
const template = (character: string, type: string) =>

View File

@@ -28,7 +28,10 @@ const numberFormats: Record<string, CardRuleEntry[]> = {
Other: [{ cardLength: 16, blocks: [4, 4, 4, 4] }],
};
@Pipe({ name: "creditCardNumber" })
@Pipe({
name: "creditCardNumber",
standalone: false,
})
export class CreditCardNumberPipe implements PipeTransform {
transform(creditCardNumber: string, brand: string): string {
let rules = numberFormats[brand];

View File

@@ -4,6 +4,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@Pipe({
name: "searchCiphers",
standalone: false,
})
export class SearchCiphersPipe implements PipeTransform {
transform(ciphers: CipherView[], searchText: string, deleted = false): CipherView[] {

View File

@@ -6,6 +6,7 @@ type PropertyValueFunction<T> = (item: T) => { toString: () => string };
@Pipe({
name: "search",
standalone: false,
})
export class SearchPipe implements PipeTransform {
transform<T>(

View File

@@ -9,6 +9,7 @@ export interface User {
@Pipe({
name: "userName",
standalone: false,
})
export class UserNamePipe implements PipeTransform {
transform(user?: User): string {

View File

@@ -5,6 +5,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
@Pipe({
name: "userType",
standalone: false,
})
export class UserTypePipe implements PipeTransform {
constructor(private i18nService: I18nService) {}

View File

@@ -2,6 +2,7 @@ import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
name: "ellipsis",
standalone: false,
})
/**
* @deprecated Use the tailwind class 'tw-truncate' instead

View File

@@ -5,6 +5,7 @@ import { KeyService } from "@bitwarden/key-management";
@Pipe({
name: "fingerprint",
standalone: false,
})
export class FingerprintPipe {
constructor(private keyService: KeyService) {}

View File

@@ -7,6 +7,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
*/
@Pipe({
name: "i18n",
standalone: false,
})
export class I18nPipe implements PipeTransform {
constructor(private i18nService: I18nService) {}

View File

@@ -0,0 +1,130 @@
# Extension Persistence
By default, when the browser extension popup closes, the user's current view and any data entered
without saving is lost. This introduces friction in several workflows within our client, such as:
- Performing actions that require email OTP entry, since the user must navigate from the popup to
get to their email inbox
- Entering information to create a new vault item from a browser tab
- And many more
Previously, we have recommended that users "pop out" the extension into its own window to persist
the extension context, but this introduces additional user actions and may leave the extension open
(and unlocked) for longer than a user intends.
In order to provide a better user experience, we have introduced two levels of persistence to the
Bitwarden extension client:
- We persist the route history, allowing us to re-open the last route when the popup re-opens, and
- We offer a service for teams to use to persist component-specific form data or state to survive a
popup close/re-open cycle
## Persistence lifetime
Since we are persisting data, it is important that the lifetime of that data be well-understood and
well-constrained. The cache of route history and form data is cleared when any of the following
events occur:
- The account is locked
- The account is logged out
- Account switching is used to switch the active account
- The extension popup has been closed for 2 minutes
In addition, cached form data is cleared when a browser extension navigation event occurs (e.g.
switching between tabs in the extension).
## Types of persistence
### Route history persistence
Route history is persisted on the extension automatically, with no specific implementation required
on any component.
The persistence layer ensures that the popup will open at the same route as was active when it
closed, provided that none of the lifetime expiration events have occurred.
:::tip Excluding a route
If a particular route should be excluded from the history and not persisted, add
`doNotSaveUrl: true` to the `data` property on the route.
:::
### View data persistence
Route persistence ensures that the user will land back on the route that they were on when the popup
closed, but it does not persist any state or form data that the user may have modified. In order to
persist that data, the component is responsible for registering that data with the
[`ViewCacheService`](./view-cache.service.ts).
This is done prescriptively to ensure that only necessary data is cached and that it is done with
intention by the component.
The `ViewCacheService` provides an interface for caching both individual state and `FormGroup`s.
#### Caching individual data elements
For individual pieces of state, use the `signal()` method on the `ViewCacheService` to create a
writeable [signal](https://angular.dev/guide/signals) wrapper around the desired state.
```typescript
const mySignal = this.viewCacheService.signal({
key: "my-state-key"
initialValue: null
});
```
If a cached value exists, the returned signal will contain the cached data.
Setting the value should be done through the signal's `set()` method:
```typescript
const mySignal = this.viewCacheService.signal({
key: "my-state-key"
initialValue: null
});
mySignal.set("value")
```
:::note Equality comparison
By default, signals use `Object.is` to determine equality, and `set()` will only trigger updates if
the updated value is not equal to the current signal state. See documentation
[here](https://angular.dev/guide/signals#signal-equality-functions).
:::
Putting this together, the most common implementation pattern would be:
1. **Register the signal** using `ViewCacheService.signal()` on initialization of the component or
service responsible for the state being persisted.
2. **Restore state from the signal:** If cached data exists, the signal will contain that data. The
component or service should use this data to re-create the state from prior to the popup closing.
3. **Set new state** in the cache when it changes. Ensure that any updates to the data are persisted
to the cache with `set()`, so that the cache reflects the latest state.
#### Caching form data
For persisting form data, the `ViewCacheService` supplies a `formGroup()` method, which manages the
persistence of any entered form data to the cache and the initialization of the form from the cached
data. You can supply the `FormGroup` in the `control` parameter of the method, and the
`ViewCacheService` will:
- Initialize the form the a cached value, if it exists
- Save form value to cache when it changes
- Mark the form dirty if the restored value is not `undefined`.
```typescript
this.loginDetailsForm = this.viewCacheService.formGroup({
key: "my-form",
control: this.formBuilder.group({
username: [""],
email: [""],
}),
});
```
## What about other clients?
The `ViewCacheService` is designed to be injected into shared, client-agnostic components. A
`NoopViewCacheService` is provided and injected for non-extension clients, preserving a single
interface for your components.

View File

@@ -0,0 +1 @@
export { ViewCacheService, FormCacheOptions, SignalCacheOptions } from "./view-cache.service";

View File

@@ -0,0 +1 @@
export { NoopViewCacheService } from "./noop-view-cache.service";

View File

@@ -1,11 +1,7 @@
import { Injectable, signal, WritableSignal } from "@angular/core";
import type { FormGroup } from "@angular/forms";
import {
FormCacheOptions,
SignalCacheOptions,
ViewCacheService,
} from "../abstractions/view-cache.service";
import { FormCacheOptions, SignalCacheOptions, ViewCacheService } from "./view-cache.service";
/**
* The functionality of the {@link ViewCacheService} is only needed in the browser extension popup,

View File

@@ -23,6 +23,12 @@ type BaseCacheOptions<T> = {
* Optional flag to persist the cached value between navigation events.
*/
persistNavigation?: boolean;
/**
* When set, the cached value will be cleared when the user changes tabs.
* @optional
*/
clearOnTabChange?: true;
} & (T extends JsonValue ? Deserializer<T> : Required<Deserializer<T>>);
export type SignalCacheOptions<T> = BaseCacheOptions<T> & {
@@ -42,6 +48,8 @@ export type FormCacheOptions<TFormGroup extends FormGroup> = BaseCacheOptions<
/**
* Cache for temporary component state
*
* [Read more](./README.md)
*
* #### Implementations
* - browser extension popup: used to persist UI between popup open and close
* - all other clients: noop

View File

@@ -1,8 +1,8 @@
@font-face {
font-family: "DM Sans";
font-family: Roboto;
src:
url("webfonts/dm-sans.woff2") format("woff2 supports variations"),
url("webfonts/dm-sans.woff2") format("woff2-variations");
url("webfonts/roboto.woff2") format("woff2 supports variations"),
url("webfonts/roboto.woff2") format("woff2-variations");
font-display: swap;
font-weight: 100 900;
}

Binary file not shown.

View File

@@ -2,6 +2,8 @@
// @ts-strict-ignore
import { Observable, Subject } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "@bitwarden/auth/common";
import { ClientType } from "@bitwarden/common/enums";
import { VaultTimeout } from "@bitwarden/common/key-management/vault-timeout";

View File

@@ -3,12 +3,16 @@
import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { Subject } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
CollectionService,
DefaultCollectionService,
DefaultOrganizationUserApiService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
@@ -27,7 +31,11 @@ import {
TwoFactorAuthComponentService,
TwoFactorAuthEmailComponentService,
TwoFactorAuthWebAuthnComponentService,
ChangePasswordService,
DefaultChangePasswordService,
} from "@bitwarden/auth/angular";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AuthRequestApiService,
AuthRequestService,
@@ -263,6 +271,7 @@ import {
InternalSendService,
SendService as SendServiceAbstraction,
} from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
@@ -281,6 +290,7 @@ import {
DefaultCipherAuthorizationService,
} from "@bitwarden/common/vault/services/cipher-authorization.service";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
@@ -312,6 +322,8 @@ import {
UserAsymmetricKeysRegenerationService,
} from "@bitwarden/key-management";
import { SafeInjectionToken } from "@bitwarden/ui-common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PasswordRepromptService } from "@bitwarden/vault";
import {
IndividualVaultExportService,
@@ -325,13 +337,14 @@ import {
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
import { LoggingErrorHandler } from "../platform/services/logging-error-handler";
import { NoopViewCacheService } from "../platform/services/noop-view-cache.service";
import { AngularThemingService } from "../platform/services/theming/angular-theming.service";
import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction";
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
import { ViewCacheService } from "../platform/view-cache";
// eslint-disable-next-line no-restricted-imports -- Needed for DI
import { NoopViewCacheService } from "../platform/view-cache/internal";
import {
CLIENT_TYPE,
@@ -508,6 +521,7 @@ const safeProviders: SafeProvider[] = [
stateProvider: StateProvider,
accountService: AccountServiceAbstraction,
logService: LogService,
cipherEncryptionService: CipherEncryptionService,
) =>
new CipherService(
keyService,
@@ -524,6 +538,7 @@ const safeProviders: SafeProvider[] = [
stateProvider,
accountService,
logService,
cipherEncryptionService,
),
deps: [
KeyService,
@@ -540,6 +555,7 @@ const safeProviders: SafeProvider[] = [
StateProvider,
AccountServiceAbstraction,
LogService,
CipherEncryptionService,
],
}),
safeProvider({
@@ -1078,7 +1094,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: OrganizationSponsorshipApiServiceAbstraction,
useClass: OrganizationSponsorshipApiService,
deps: [ApiServiceAbstraction],
deps: [ApiServiceAbstraction, PlatformUtilsServiceAbstraction],
}),
safeProvider({
provide: OrganizationBillingApiServiceAbstraction,
@@ -1527,6 +1543,20 @@ const safeProviders: SafeProvider[] = [
useClass: MasterPasswordApiService,
deps: [ApiServiceAbstraction, LogService],
}),
safeProvider({
provide: CipherEncryptionService,
useClass: DefaultCipherEncryptionService,
deps: [SdkService, LogService],
}),
safeProvider({
provide: ChangePasswordService,
useClass: DefaultChangePasswordService,
deps: [
KeyService,
MasterPasswordApiServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
],
}),
];
@NgModule({

View File

@@ -3,6 +3,8 @@ import { TestBed } from "@angular/core/testing";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { openPasswordHistoryDialog } from "@bitwarden/vault";
import { VaultViewPasswordHistoryService } from "./view-password-history.service";

View File

@@ -3,6 +3,8 @@ 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";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { openPasswordHistoryDialog } from "@bitwarden/vault";
/**

View File

@@ -16,6 +16,7 @@ export interface PasswordColorText {
@Component({
selector: "app-password-strength",
templateUrl: "password-strength.component.html",
standalone: false,
})
export class PasswordStrengthComponent implements OnChanges {
@Input() showText = false;

View File

@@ -36,6 +36,8 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service.
import { DialogService, ToastService } from "@bitwarden/components";
// Value = hours
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
enum DatePreset {
OneHour = 1,
OneDay = 24,

View File

@@ -2,6 +2,8 @@
// @ts-strict-ignore
import { Observable } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";

View File

@@ -4,6 +4,8 @@ import { DatePipe } from "@angular/common";
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { concatMap, firstValueFrom, map, Observable, Subject, switchMap, takeUntil } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
@@ -40,6 +42,8 @@ import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { generate_ssh_key } from "@bitwarden/sdk-internal";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault";
@Directive()
@@ -269,9 +273,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
if (this.cipher == null) {
if (this.editMode) {
const cipher = await this.loadCipher(activeUserId);
this.cipher = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
this.cipher = await this.cipherService.decrypt(cipher, activeUserId);
// Adjust Cipher Name if Cloning
if (this.cloneMode) {

View File

@@ -9,13 +9,13 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
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";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
@@ -56,6 +56,7 @@ export class AttachmentsComponent implements OnInit {
protected billingAccountProfileStateService: BillingAccountProfileStateService,
protected accountService: AccountService,
protected toastService: ToastService,
protected configService: ConfigService,
) {}
async ngOnInit() {
@@ -88,9 +89,7 @@ export class AttachmentsComponent implements OnInit {
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.formPromise = this.saveCipherAttachment(files[0], activeUserId);
this.cipherDomain = await this.formPromise;
this.cipher = await this.cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId),
);
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
@@ -130,9 +129,7 @@ export class AttachmentsComponent implements OnInit {
const updatedCipher = await this.deletePromises[attachment.id];
const cipher = new Cipher(updatedCipher);
this.cipher = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
this.cipher = await this.cipherService.decrypt(cipher, activeUserId);
this.toastService.showToast({
variant: "success",
@@ -197,12 +194,14 @@ export class AttachmentsComponent implements OnInit {
}
try {
const encBuf = await EncArrayBuffer.fromResponse(response);
const key =
attachment.key != null
? attachment.key
: await this.keyService.getOrgKey(this.cipher.organizationId);
const decBuf = await this.encryptService.decryptFileData(encBuf, key);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
this.cipherDomain.id as CipherId,
attachment,
response,
activeUserId,
);
this.fileDownloadService.download({
fileName: attachment.fileName,
blobData: decBuf,
@@ -228,9 +227,7 @@ export class AttachmentsComponent implements OnInit {
protected async init() {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.cipherDomain = await this.loadCipher(activeUserId);
this.cipher = await this.cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId),
);
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
const canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId),
@@ -276,15 +273,17 @@ export class AttachmentsComponent implements OnInit {
try {
// 2. Resave
const encBuf = await EncArrayBuffer.fromResponse(response);
const key =
attachment.key != null
? attachment.key
: await this.keyService.getOrgKey(this.cipher.organizationId);
const decBuf = await this.encryptService.decryptFileData(encBuf, key);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
);
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
this.cipherDomain.id as CipherId,
attachment,
response,
activeUserId,
);
this.cipherDomain = await this.cipherService.saveAttachmentRawWithServer(
this.cipherDomain,
attachment.fileName,
@@ -292,9 +291,7 @@ export class AttachmentsComponent implements OnInit {
activeUserId,
admin,
);
this.cipher = await this.cipherDomain.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId),
);
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
// 3. Delete old
this.deletePromises[attachment.id] = this.deleteCipherAttachment(

View File

@@ -19,6 +19,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
selector: "app-vault-icon",
templateUrl: "icon.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class IconComponent {
/**

View File

@@ -42,9 +42,7 @@ export class PasswordHistoryComponent implements OnInit {
protected async init() {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipher = await this.cipherService.get(this.cipherId, activeUserId);
const decCipher = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
const decCipher = await this.cipherService.decrypt(cipher, activeUserId);
this.history = decCipher.passwordHistory == null ? [] : decCipher.passwordHistory;
}
}

View File

@@ -4,7 +4,13 @@
<div class="tw-flex tw-justify-between tw-items-start tw-flex-grow">
<div>
<h2 bitTypography="h4" class="tw-font-semibold !tw-mb-1">{{ title }}</h2>
<p class="tw-text-main tw-mb-0" bitTypography="body2" [innerHTML]="subtitle"></p>
<p
*ngIf="subtitle"
class="tw-text-main tw-mb-0"
bitTypography="body2"
[innerHTML]="subtitle"
></p>
<ng-content *ngIf="!subtitle"></ng-content>
</div>
<button
type="button"

View File

@@ -14,7 +14,7 @@ export class SpotlightComponent {
// The title of the component
@Input({ required: true }) title: string | null = null;
// The subtitle of the component
@Input({ required: true }) subtitle: string | null = null;
@Input() subtitle?: string | null = null;
// The text to display on the button
@Input() buttonText?: string;
// Wheter the component can be dismissed, if true, the component will not show a close button

View File

@@ -34,13 +34,13 @@ import { EventType } from "@bitwarden/common/enums";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
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";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherId, CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
@@ -54,6 +54,8 @@ import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cip
import { TotpInfo } from "@bitwarden/common/vault/services/totp.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PasswordRepromptService } from "@bitwarden/vault";
const BroadcasterSubscriptionId = "BaseViewComponent";
@@ -95,7 +97,7 @@ export class ViewComponent implements OnDestroy, OnInit {
cipherType = CipherType;
private previousCipherId: string;
private passwordReprompted = false;
protected passwordReprompted = false;
/**
* Represents TOTP information including display formatting and timing
@@ -137,6 +139,7 @@ export class ViewComponent implements OnDestroy, OnInit {
private billingAccountProfileStateService: BillingAccountProfileStateService,
protected toastService: ToastService,
private cipherAuthorizationService: CipherAuthorizationService,
protected configService: ConfigService,
) {}
ngOnInit() {
@@ -458,19 +461,19 @@ export class ViewComponent implements OnDestroy, OnInit {
}
try {
const encBuf = await EncArrayBuffer.fromResponse(response);
const key =
attachment.key != null
? attachment.key
: await this.keyService.getOrgKey(this.cipher.organizationId);
const decBuf = await this.encryptService.decryptFileData(encBuf, key);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
this.cipher.id as CipherId,
attachment,
response,
activeUserId,
);
this.fileDownloadService.download({
fileName: attachment.fileName,
blobData: decBuf,
});
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
} catch {
this.toastService.showToast({
variant: "error",
title: null,

View File

@@ -0,0 +1,3 @@
// Note: Nudge related code is exported from `libs/angular` because it is consumed by multiple
// `libs/*` packages. Exporting from the `libs/vault` package creates circular dependencies.
export { NudgesService, NudgeStatus, NudgeType } from "./services/nudges.service";

View File

@@ -0,0 +1,51 @@
import { Injectable, inject } from "@angular/core";
import { Observable, combineLatest, from, of } from "rxjs";
import { catchError, map } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
@Injectable({ providedIn: "root" })
export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
private vaultProfileService = inject(VaultProfileService);
private logService = inject(LogService);
private pinService = inject(PinServiceAbstraction);
private vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
catchError(() => {
this.logService.error("Failed to load profile date:");
// Default to today to ensure the nudge is shown in case of an error
return of(new Date());
}),
);
return combineLatest([
profileDate$,
this.getNudgeStatus$(nudgeType, userId),
of(Date.now() - THIRTY_DAYS_MS),
from(this.pinService.isPinSet(userId)),
from(this.vaultTimeoutSettingsService.isBiometricLockSet(userId)),
]).pipe(
map(([profileCreationDate, status, profileCutoff, isPinSet, isBiometricLockSet]) => {
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
const hideNudge = profileOlderThanCutoff || isPinSet || isBiometricLockSet;
return {
hasBadgeDismissed: status.hasBadgeDismissed || hideNudge,
hasSpotlightDismissed: status.hasSpotlightDismissed || hideNudge,
};
}),
);
}
}

View File

@@ -7,7 +7,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
@@ -21,7 +21,7 @@ export class AutofillNudgeService extends DefaultSingleNudgeService {
vaultProfileService = inject(VaultProfileService);
logService = inject(LogService);
nudgeStatus$(_: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
nudgeStatus$(_: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
catchError(() => {
this.logService.error("Error getting profile creation date");
@@ -32,7 +32,7 @@ export class AutofillNudgeService extends DefaultSingleNudgeService {
return combineLatest([
profileDate$,
this.getNudgeStatus$(VaultNudgeType.AutofillNudge, userId),
this.getNudgeStatus$(NudgeType.AutofillNudge, userId),
of(Date.now() - THIRTY_DAYS_MS),
]).pipe(
map(([profileCreationDate, status, profileCutoff]) => {

View File

@@ -7,7 +7,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
@@ -16,7 +16,7 @@ export class DownloadBitwardenNudgeService extends DefaultSingleNudgeService {
private vaultProfileService = inject(VaultProfileService);
private logService = inject(LogService);
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
catchError(() => {
this.logService.error("Failed to load profile date:");

View File

@@ -1,13 +1,15 @@
import { inject, Injectable } from "@angular/core";
import { combineLatest, Observable, of, switchMap } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
/**
* Custom Nudge Service Checking Nudge Status For Empty Vault
@@ -20,7 +22,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
organizationService = inject(OrganizationService);
collectionService = inject(CollectionService);
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
@@ -28,7 +30,10 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
this.collectionService.decryptedCollections$,
]).pipe(
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
const vaultHasContents = !(ciphers == null || ciphers.length === 0);
const filteredCiphers = ciphers?.filter((cipher) => {
return cipher.deletedDate == null;
});
const vaultHasContents = !(filteredCiphers == null || filteredCiphers.length === 0);
if (orgs == null || orgs.length === 0) {
return nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed
? of(nudgeStatus)

View File

@@ -0,0 +1,67 @@
import { inject, Injectable } from "@angular/core";
import { combineLatest, from, Observable, of, switchMap } from "rxjs";
import { catchError } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Custom Nudge Service Checking Nudge Status For Welcome Nudge With Populated Vault
*/
@Injectable({
providedIn: "root",
})
export class HasItemsNudgeService extends DefaultSingleNudgeService {
cipherService = inject(CipherService);
vaultProfileService = inject(VaultProfileService);
logService = inject(LogService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
catchError(() => {
this.logService.error("Error getting profile creation date");
// Default to today to ensure we show the nudge
return of(new Date());
}),
);
return combineLatest([
this.cipherService.cipherViews$(userId),
this.getNudgeStatus$(nudgeType, userId),
profileDate$,
of(Date.now() - THIRTY_DAYS_MS),
]).pipe(
switchMap(async ([ciphers, nudgeStatus, profileDate, profileCutoff]) => {
const profileOlderThanCutoff = profileDate.getTime() < profileCutoff;
const filteredCiphers = ciphers?.filter((cipher) => {
return cipher.deletedDate == null;
});
if (profileOlderThanCutoff && filteredCiphers.length > 0) {
const dismissedStatus = {
hasSpotlightDismissed: true,
hasBadgeDismissed: true,
};
// permanently dismiss both the Empty Vault Nudge and Has Items Vault Nudge if the profile is older than 30 days
await this.setNudgeStatus(nudgeType, dismissedStatus, userId);
await this.setNudgeStatus(NudgeType.EmptyVaultNudge, dismissedStatus, userId);
return dismissedStatus;
} else if (nudgeStatus.hasSpotlightDismissed) {
return nudgeStatus;
} else {
return {
hasBadgeDismissed: filteredCiphers == null || filteredCiphers.length === 0,
hasSpotlightDismissed: filteredCiphers == null || filteredCiphers.length === 0,
};
}
}),
);
}
}

View File

@@ -1,4 +1,5 @@
export * from "./autofill-nudge.service";
export * from "./account-security-nudge.service";
export * from "./has-items-nudge.service";
export * from "./download-bitwarden-nudge.service";
export * from "./empty-vault-nudge.service";

View File

@@ -6,7 +6,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { CipherType } from "@bitwarden/common/vault/enums";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
/**
* Custom Nudge Service Checking Nudge Status For Vault New Item Types
@@ -17,7 +17,7 @@ import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
export class NewItemNudgeService extends DefaultSingleNudgeService {
cipherService = inject(CipherService);
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
@@ -30,19 +30,19 @@ export class NewItemNudgeService extends DefaultSingleNudgeService {
let currentType: CipherType;
switch (nudgeType) {
case VaultNudgeType.newLoginItemStatus:
case NudgeType.NewLoginItemStatus:
currentType = CipherType.Login;
break;
case VaultNudgeType.newCardItemStatus:
case NudgeType.NewCardItemStatus:
currentType = CipherType.Card;
break;
case VaultNudgeType.newIdentityItemStatus:
case NudgeType.NewIdentityItemStatus:
currentType = CipherType.Identity;
break;
case VaultNudgeType.newNoteItemStatus:
case NudgeType.NewNoteItemStatus:
currentType = CipherType.SecureNote;
break;
case VaultNudgeType.newSshItemStatus:
case NudgeType.NewSshItemStatus:
currentType = CipherType.SshKey;
break;
}

View File

@@ -4,19 +4,15 @@ import { map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import {
NudgeStatus,
VAULT_NUDGE_DISMISSED_DISK_KEY,
VaultNudgeType,
} from "./vault-nudges.service";
import { NudgeStatus, NUDGE_DISMISSED_DISK_KEY, NudgeType } from "./nudges.service";
/**
* Base interface for handling a nudge's status
*/
export interface SingleNudgeService {
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus>;
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus>;
setNudgeStatus(nudgeType: VaultNudgeType, newStatus: NudgeStatus, userId: UserId): Promise<void>;
setNudgeStatus(nudgeType: NudgeType, newStatus: NudgeStatus, userId: UserId): Promise<void>;
}
/**
@@ -28,9 +24,9 @@ export interface SingleNudgeService {
export class DefaultSingleNudgeService implements SingleNudgeService {
stateProvider = inject(StateProvider);
protected getNudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
protected getNudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return this.stateProvider
.getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY)
.getUser(userId, NUDGE_DISMISSED_DISK_KEY)
.state$.pipe(
map(
(nudges) =>
@@ -39,16 +35,12 @@ export class DefaultSingleNudgeService implements SingleNudgeService {
);
}
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return this.getNudgeStatus$(nudgeType, userId);
}
async setNudgeStatus(
nudgeType: VaultNudgeType,
status: NudgeStatus,
userId: UserId,
): Promise<void> {
await this.stateProvider.getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY).update((nudges) => {
async setNudgeStatus(nudgeType: NudgeType, status: NudgeStatus, userId: UserId): Promise<void> {
await this.stateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update((nudges) => {
nudges ??= {};
nudges[nudgeType] = status;
return nudges;

View File

@@ -2,15 +2,19 @@ import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FakeStateProvider, mockAccountServiceWith } from "../../../common/spec";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../libs/common/spec";
import {
HasItemsNudgeService,
@@ -18,7 +22,7 @@ import {
DownloadBitwardenNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
import { VaultNudgesService, VaultNudgeType } from "./vault-nudges.service";
import { NudgesService, NudgeType } from "./nudges.service";
describe("Vault Nudges Service", () => {
let fakeStateProvider: FakeStateProvider;
@@ -29,7 +33,7 @@ describe("Vault Nudges Service", () => {
getFeatureFlag: jest.fn().mockReturnValue(true),
};
const vaultNudgeServices = [EmptyVaultNudgeService, DownloadBitwardenNudgeService];
const nudgeServices = [EmptyVaultNudgeService, DownloadBitwardenNudgeService];
beforeEach(async () => {
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
@@ -38,7 +42,7 @@ describe("Vault Nudges Service", () => {
imports: [],
providers: [
{
provide: VaultNudgesService,
provide: NudgesService,
},
{
provide: DefaultSingleNudgeService,
@@ -74,6 +78,14 @@ describe("Vault Nudges Service", () => {
provide: LogService,
useValue: mock<LogService>(),
},
{
provide: PinServiceAbstraction,
useValue: mock<PinServiceAbstraction>(),
},
{
provide: VaultTimeoutSettingsService,
useValue: mock<VaultTimeoutSettingsService>(),
},
],
});
});
@@ -83,13 +95,13 @@ describe("Vault Nudges Service", () => {
const service = testBed.inject(DefaultSingleNudgeService);
await service.setNudgeStatus(
VaultNudgeType.EmptyVaultNudge,
NudgeType.EmptyVaultNudge,
{ hasBadgeDismissed: true, hasSpotlightDismissed: true },
"user-id" as UserId,
);
const result = await firstValueFrom(
service.nudgeStatus$(VaultNudgeType.EmptyVaultNudge, "user-id" as UserId),
service.nudgeStatus$(NudgeType.EmptyVaultNudge, "user-id" as UserId),
);
expect(result).toEqual({ hasBadgeDismissed: true, hasSpotlightDismissed: true });
});
@@ -98,27 +110,27 @@ describe("Vault Nudges Service", () => {
const service = testBed.inject(DefaultSingleNudgeService);
await service.setNudgeStatus(
VaultNudgeType.EmptyVaultNudge,
NudgeType.EmptyVaultNudge,
{ hasBadgeDismissed: false, hasSpotlightDismissed: false },
"user-id" as UserId,
);
const result = await firstValueFrom(
service.nudgeStatus$(VaultNudgeType.EmptyVaultNudge, "user-id" as UserId),
service.nudgeStatus$(NudgeType.EmptyVaultNudge, "user-id" as UserId),
);
expect(result).toEqual({ hasBadgeDismissed: false, hasSpotlightDismissed: false });
});
});
describe("VaultNudgesService", () => {
describe("NudgesService", () => {
it("should return true, the proper value from the custom nudge service nudgeStatus$", async () => {
TestBed.overrideProvider(HasItemsNudgeService, {
useValue: { nudgeStatus$: () => of(true) },
});
const service = testBed.inject(VaultNudgesService);
const service = testBed.inject(NudgesService);
const result = await firstValueFrom(
service.showNudge$(VaultNudgeType.HasVaultItems, "user-id" as UserId),
service.showNudgeStatus$(NudgeType.HasVaultItems, "user-id" as UserId),
);
expect(result).toBe(true);
@@ -128,10 +140,40 @@ describe("Vault Nudges Service", () => {
TestBed.overrideProvider(HasItemsNudgeService, {
useValue: { nudgeStatus$: () => of(false) },
});
const service = testBed.inject(VaultNudgesService);
const service = testBed.inject(NudgesService);
const result = await firstValueFrom(
service.showNudge$(VaultNudgeType.HasVaultItems, "user-id" as UserId),
service.showNudgeStatus$(NudgeType.HasVaultItems, "user-id" as UserId),
);
expect(result).toBe(false);
});
it("should return showNudgeSpotlight$ false if hasSpotLightDismissed is true", async () => {
TestBed.overrideProvider(HasItemsNudgeService, {
useValue: {
nudgeStatus$: () => of({ hasSpotlightDismissed: true, hasBadgeDismissed: true }),
},
});
const service = testBed.inject(NudgesService);
const result = await firstValueFrom(
service.showNudgeSpotlight$(NudgeType.HasVaultItems, "user-id" as UserId),
);
expect(result).toBe(false);
});
it("should return showNudgeBadge$ false when hasBadgeDismissed is true", async () => {
TestBed.overrideProvider(HasItemsNudgeService, {
useValue: {
nudgeStatus$: () => of({ hasSpotlightDismissed: true, hasBadgeDismissed: true }),
},
});
const service = testBed.inject(NudgesService);
const result = await firstValueFrom(
service.showNudgeBadge$(NudgeType.HasVaultItems, "user-id" as UserId),
);
expect(result).toBe(false);
@@ -140,7 +182,7 @@ describe("Vault Nudges Service", () => {
describe("HasActiveBadges", () => {
it("should return true if a nudgeType with hasBadgeDismissed === false", async () => {
vaultNudgeServices.forEach((service) => {
nudgeServices.forEach((service) => {
TestBed.overrideProvider(service, {
useValue: {
nudgeStatus$: () => of({ hasBadgeDismissed: false, hasSpotlightDismissed: false }),
@@ -148,21 +190,21 @@ describe("Vault Nudges Service", () => {
});
});
const service = testBed.inject(VaultNudgesService);
const service = testBed.inject(NudgesService);
const result = await firstValueFrom(service.hasActiveBadges$("user-id" as UserId));
expect(result).toBe(true);
});
it("should return false if all nudgeTypes have hasBadgeDismissed === true", async () => {
vaultNudgeServices.forEach((service) => {
nudgeServices.forEach((service) => {
TestBed.overrideProvider(service, {
useValue: {
nudgeStatus$: () => of({ hasBadgeDismissed: true, hasSpotlightDismissed: false }),
},
});
});
const service = testBed.inject(VaultNudgesService);
const service = testBed.inject(NudgesService);
const result = await firstValueFrom(service.hasActiveBadges$("user-id" as UserId));

View File

@@ -3,7 +3,7 @@ import { combineLatest, map, Observable, of, shareReplay, switchMap } from "rxjs
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserKeyDefinition, VAULT_NUDGES_DISK } from "@bitwarden/common/platform/state";
import { UserKeyDefinition, NUDGES_DISK } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import {
@@ -12,6 +12,7 @@ import {
AutofillNudgeService,
DownloadBitwardenNudgeService,
NewItemNudgeService,
AccountSecurityNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
@@ -23,24 +24,29 @@ export type NudgeStatus = {
/**
* Enum to list the various nudge types, to be used by components/badges to show/hide the nudge
*/
export enum VaultNudgeType {
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum NudgeType {
/** Nudge to show when user has no items in their vault
* Add future nudges here
*/
EmptyVaultNudge = "empty-vault-nudge",
HasVaultItems = "has-vault-items",
AutofillNudge = "autofill-nudge",
AccountSecurity = "account-security",
DownloadBitwarden = "download-bitwarden",
newLoginItemStatus = "new-login-item-status",
newCardItemStatus = "new-card-item-status",
newIdentityItemStatus = "new-identity-item-status",
newNoteItemStatus = "new-note-item-status",
newSshItemStatus = "new-ssh-item-status",
NewLoginItemStatus = "new-login-item-status",
NewCardItemStatus = "new-card-item-status",
NewIdentityItemStatus = "new-identity-item-status",
NewNoteItemStatus = "new-note-item-status",
NewSshItemStatus = "new-ssh-item-status",
GeneratorNudgeStatus = "generator-nudge-status",
SendNudgeStatus = "send-nudge-status",
}
export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
Partial<Record<VaultNudgeType, NudgeStatus>>
>(VAULT_NUDGES_DISK, "vaultNudgeDismissed", {
export const NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
Partial<Record<NudgeType, NudgeStatus>>
>(NUDGES_DISK, "vaultNudgeDismissed", {
deserializer: (nudge) => nudge,
clearOn: [], // Do not clear dismissals
});
@@ -48,7 +54,7 @@ export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
@Injectable({
providedIn: "root",
})
export class VaultNudgesService {
export class NudgesService {
private newItemNudgeService = inject(NewItemNudgeService);
/**
@@ -56,16 +62,17 @@ export class VaultNudgesService {
* Each nudge type can have its own service to determine when to show the nudge
* @private
*/
private customNudgeServices: Partial<Record<VaultNudgeType, SingleNudgeService>> = {
[VaultNudgeType.HasVaultItems]: inject(HasItemsNudgeService),
[VaultNudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService),
[VaultNudgeType.AutofillNudge]: inject(AutofillNudgeService),
[VaultNudgeType.DownloadBitwarden]: inject(DownloadBitwardenNudgeService),
[VaultNudgeType.newLoginItemStatus]: this.newItemNudgeService,
[VaultNudgeType.newCardItemStatus]: this.newItemNudgeService,
[VaultNudgeType.newIdentityItemStatus]: this.newItemNudgeService,
[VaultNudgeType.newNoteItemStatus]: this.newItemNudgeService,
[VaultNudgeType.newSshItemStatus]: this.newItemNudgeService,
private customNudgeServices: Partial<Record<NudgeType, SingleNudgeService>> = {
[NudgeType.HasVaultItems]: inject(HasItemsNudgeService),
[NudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService),
[NudgeType.AccountSecurity]: inject(AccountSecurityNudgeService),
[NudgeType.AutofillNudge]: inject(AutofillNudgeService),
[NudgeType.DownloadBitwarden]: inject(DownloadBitwardenNudgeService),
[NudgeType.NewLoginItemStatus]: this.newItemNudgeService,
[NudgeType.NewCardItemStatus]: this.newItemNudgeService,
[NudgeType.NewIdentityItemStatus]: this.newItemNudgeService,
[NudgeType.NewNoteItemStatus]: this.newItemNudgeService,
[NudgeType.NewSshItemStatus]: this.newItemNudgeService,
};
/**
@@ -76,16 +83,52 @@ export class VaultNudgesService {
private defaultNudgeService = inject(DefaultSingleNudgeService);
private configService = inject(ConfigService);
private getNudgeService(nudge: VaultNudgeType): SingleNudgeService {
private getNudgeService(nudge: NudgeType): SingleNudgeService {
return this.customNudgeServices[nudge] ?? this.defaultNudgeService;
}
/**
* Check if a nudge Spotlight should be shown to the user
* @param nudge
* @param userId
*/
showNudgeSpotlight$(nudge: NudgeType, userId: UserId): Observable<boolean> {
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
switchMap((hasVaultNudgeFlag) => {
if (!hasVaultNudgeFlag) {
return of(false);
}
return this.getNudgeService(nudge)
.nudgeStatus$(nudge, userId)
.pipe(map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed));
}),
);
}
/**
* Check if a nudge Badge should be shown to the user
* @param nudge
* @param userId
*/
showNudgeBadge$(nudge: NudgeType, userId: UserId): Observable<boolean> {
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
switchMap((hasVaultNudgeFlag) => {
if (!hasVaultNudgeFlag) {
return of(false);
}
return this.getNudgeService(nudge)
.nudgeStatus$(nudge, userId)
.pipe(map((nudgeStatus) => !nudgeStatus.hasBadgeDismissed));
}),
);
}
/**
* Check if a nudge should be shown to the user
* @param nudge
* @param userId
*/
showNudge$(nudge: VaultNudgeType, userId: UserId) {
showNudgeStatus$(nudge: NudgeType, userId: UserId) {
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
switchMap((hasVaultNudgeFlag) => {
if (!hasVaultNudgeFlag) {
@@ -101,7 +144,7 @@ export class VaultNudgesService {
* @param nudge
* @param userId
*/
async dismissNudge(nudge: VaultNudgeType, userId: UserId, onlyBadge: boolean = false) {
async dismissNudge(nudge: NudgeType, userId: UserId, onlyBadge: boolean = false) {
const dismissedStatus = onlyBadge
? { hasBadgeDismissed: true, hasSpotlightDismissed: false }
: { hasBadgeDismissed: true, hasSpotlightDismissed: true };
@@ -114,7 +157,7 @@ export class VaultNudgesService {
*/
hasActiveBadges$(userId: UserId): Observable<boolean> {
// Add more nudge types here if they have the settings badge feature
const nudgeTypes = [VaultNudgeType.EmptyVaultNudge, VaultNudgeType.DownloadBitwarden];
const nudgeTypes = [NudgeType.EmptyVaultNudge, NudgeType.DownloadBitwarden];
const nudgeTypesWithBadge$ = nudgeTypes.map((nudge) => {
return this.getNudgeService(nudge)

View File

@@ -2,6 +2,8 @@
// @ts-strict-ignore
import { Directive, EventEmitter, Input, Output } from "@angular/core";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";

View File

@@ -3,6 +3,8 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom, Observable } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";

View File

@@ -3,6 +3,8 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, from, map, mergeMap, Observable, switchMap, take } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";

View File

@@ -6,6 +6,8 @@ import { Subject, filter, switchMap, takeUntil, tap } from "rxjs";
import { AnonLayoutComponent } from "@bitwarden/auth/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { Icon, Translation } from "@bitwarden/components";
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";

View File

@@ -15,6 +15,8 @@ import {
Environment,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { ButtonModule } from "@bitwarden/components";
// FIXME: remove `/apps` import from `/libs`

View File

@@ -0,0 +1,20 @@
@if (initializing) {
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
} @else {
<auth-input-password
[flow]="inputPasswordFlow"
[email]="email"
[userId]="userId"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[inlineButtons]="true"
[primaryButtonText]="{ key: 'changeMasterPassword' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
>
</auth-input-password>
}

View File

@@ -0,0 +1,112 @@
import { Component, Input, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { ToastService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
InputPasswordComponent,
InputPasswordFlow,
} from "../input-password/input-password.component";
import { PasswordInputResult } from "../input-password/password-input-result";
import { ChangePasswordService } from "./change-password.service.abstraction";
@Component({
standalone: true,
selector: "auth-change-password",
templateUrl: "change-password.component.html",
imports: [InputPasswordComponent, I18nPipe],
})
export class ChangePasswordComponent implements OnInit {
@Input() inputPasswordFlow: InputPasswordFlow = InputPasswordFlow.ChangePassword;
activeAccount: Account | null = null;
email?: string;
userId?: UserId;
masterPasswordPolicyOptions?: MasterPasswordPolicyOptions;
initializing = true;
submitting = false;
constructor(
private accountService: AccountService,
private changePasswordService: ChangePasswordService,
private i18nService: I18nService,
private messagingService: MessagingService,
private policyService: PolicyService,
private toastService: ToastService,
private syncService: SyncService,
) {}
async ngOnInit() {
this.activeAccount = await firstValueFrom(this.accountService.activeAccount$);
this.userId = this.activeAccount?.id;
this.email = this.activeAccount?.email;
if (!this.userId) {
throw new Error("userId not found");
}
this.masterPasswordPolicyOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(this.userId),
);
this.initializing = false;
}
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
try {
if (passwordInputResult.rotateUserKey) {
if (this.activeAccount == null) {
throw new Error("activeAccount not found");
}
if (passwordInputResult.currentPassword == null) {
throw new Error("currentPassword not found");
}
await this.syncService.fullSync(true);
await this.changePasswordService.rotateUserKeyMasterPasswordAndEncryptedData(
passwordInputResult.currentPassword,
passwordInputResult.newPassword,
this.activeAccount,
passwordInputResult.newPasswordHint,
);
} else {
if (!this.userId) {
throw new Error("userId not found");
}
await this.changePasswordService.changePassword(passwordInputResult, this.userId);
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("masterPasswordChanged"),
message: this.i18nService.t("masterPasswordChangedDesc"),
});
this.messagingService.send("logout");
}
} catch {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("errorOccurred"),
});
} finally {
this.submitting = false;
}
}
}

View File

@@ -0,0 +1,36 @@
import { PasswordInputResult } from "@bitwarden/auth/angular";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
export abstract class ChangePasswordService {
/**
* Creates a new user key and re-encrypts all required data with it.
* - does so by calling the underlying method on the `UserKeyRotationService`
* - implemented in Web only
*
* @param currentPassword the current password
* @param newPassword the new password
* @param user the user account
* @param newPasswordHint the new password hint
* @throws if called from a non-Web client
*/
abstract rotateUserKeyMasterPasswordAndEncryptedData(
currentPassword: string,
newPassword: string,
user: Account,
newPasswordHint: string,
): Promise<void>;
/**
* Changes the user's password and re-encrypts the user key with the `newMasterKey`.
* - Specifically, this method uses credentials from the `passwordInputResult` to:
* 1. Decrypt the user key with the `currentMasterKey`
* 2. Re-encrypt that user key with the `newMasterKey`, resulting in a `newMasterKeyEncryptedUserKey`
* 3. Build a `PasswordRequest` object that gets POSTed to `"/accounts/password"`
*
* @param passwordInputResult credentials object received from the `InputPasswordComponent`
* @param userId the `userId`
* @throws if the `userId`, `currentMasterKey`, or `currentServerMasterKeyHash` is not found
*/
abstract changePassword(passwordInputResult: PasswordInputResult, userId: UserId): Promise<void>;
}

View File

@@ -0,0 +1,177 @@
import { mock, MockProxy } from "jest-mock-extended";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { PasswordInputResult } from "../input-password/password-input-result";
import { ChangePasswordService } from "./change-password.service.abstraction";
import { DefaultChangePasswordService } from "./default-change-password.service";
describe("DefaultChangePasswordService", () => {
let keyService: MockProxy<KeyService>;
let masterPasswordApiService: MockProxy<MasterPasswordApiService>;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let sut: ChangePasswordService;
const userId = "userId" as UserId;
const user: Account = {
id: userId,
email: "email",
emailVerified: false,
name: "name",
};
const passwordInputResult: PasswordInputResult = {
currentMasterKey: new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
currentServerMasterKeyHash: "currentServerMasterKeyHash",
newPassword: "newPassword",
newPasswordHint: "newPasswordHint",
newMasterKey: new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newLocalMasterKeyHash: "newLocalMasterKeyHash",
kdfConfig: new PBKDF2KdfConfig(),
};
const decryptedUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const newMasterKeyEncryptedUserKey: [UserKey, EncString] = [
decryptedUserKey,
{ encryptedString: "newMasterKeyEncryptedUserKey" } as EncString,
];
beforeEach(() => {
keyService = mock<KeyService>();
masterPasswordApiService = mock<MasterPasswordApiService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
sut = new DefaultChangePasswordService(
keyService,
masterPasswordApiService,
masterPasswordService,
);
masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(decryptedUserKey);
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(newMasterKeyEncryptedUserKey);
});
describe("changePassword()", () => {
it("should call the postPassword() API method with a the correct PasswordRequest credentials", async () => {
// Act
await sut.changePassword(passwordInputResult, userId);
// Assert
expect(masterPasswordApiService.postPassword).toHaveBeenCalledWith(
expect.objectContaining({
masterPasswordHash: passwordInputResult.currentServerMasterKeyHash,
masterPasswordHint: passwordInputResult.newPasswordHint,
newMasterPasswordHash: passwordInputResult.newServerMasterKeyHash,
key: newMasterKeyEncryptedUserKey[1].encryptedString,
}),
);
});
it("should call decryptUserKeyWithMasterKey and encryptUserKeyWithMasterKey", async () => {
// Act
await sut.changePassword(passwordInputResult, userId);
// Assert
expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
passwordInputResult.currentMasterKey,
userId,
);
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
passwordInputResult.newMasterKey,
decryptedUserKey,
);
});
it("should throw if a userId was not found", async () => {
// Arrange
const userId: null = null;
// Act
const testFn = sut.changePassword(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("userId not found");
});
it("should throw if a currentMasterKey was not found", async () => {
// Arrange
const incorrectPasswordInputResult = { ...passwordInputResult };
incorrectPasswordInputResult.currentMasterKey = null;
// Act
const testFn = sut.changePassword(incorrectPasswordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow(
"currentMasterKey or currentServerMasterKeyHash not found",
);
});
it("should throw if a currentServerMasterKeyHash was not found", async () => {
// Arrange
const incorrectPasswordInputResult = { ...passwordInputResult };
incorrectPasswordInputResult.currentServerMasterKeyHash = null;
// Act
const testFn = sut.changePassword(incorrectPasswordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow(
"currentMasterKey or currentServerMasterKeyHash not found",
);
});
it("should throw an error if user key decryption fails", async () => {
// Arrange
masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(null);
// Act
const testFn = sut.changePassword(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("Could not decrypt user key");
});
it("should throw an error if postPassword() fails", async () => {
// Arrange
masterPasswordApiService.postPassword.mockRejectedValueOnce(new Error("error"));
// Act
const testFn = sut.changePassword(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("Could not change password");
expect(masterPasswordApiService.postPassword).toHaveBeenCalled();
});
});
describe("rotateUserKeyMasterPasswordAndEncryptedData()", () => {
it("should throw an error (the method is only implemented in Web)", async () => {
// Act
const testFn = sut.rotateUserKeyMasterPasswordAndEncryptedData(
"currentPassword",
"newPassword",
user,
"newPasswordHint",
);
// Assert
await expect(testFn).rejects.toThrow(
"rotateUserKeyMasterPasswordAndEncryptedData() is only implemented in Web",
);
});
});
});

View File

@@ -0,0 +1,59 @@
import { PasswordInputResult, ChangePasswordService } from "@bitwarden/auth/angular";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
export class DefaultChangePasswordService implements ChangePasswordService {
constructor(
protected keyService: KeyService,
protected masterPasswordApiService: MasterPasswordApiService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
) {}
async rotateUserKeyMasterPasswordAndEncryptedData(
currentPassword: string,
newPassword: string,
user: Account,
hint: string,
): Promise<void> {
throw new Error("rotateUserKeyMasterPasswordAndEncryptedData() is only implemented in Web");
}
async changePassword(passwordInputResult: PasswordInputResult, userId: UserId) {
if (!userId) {
throw new Error("userId not found");
}
if (!passwordInputResult.currentMasterKey || !passwordInputResult.currentServerMasterKeyHash) {
throw new Error("currentMasterKey or currentServerMasterKeyHash not found");
}
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
passwordInputResult.currentMasterKey,
userId,
);
if (decryptedUserKey == null) {
throw new Error("Could not decrypt user key");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
passwordInputResult.newMasterKey,
decryptedUserKey,
);
const request = new PasswordRequest();
request.masterPasswordHash = passwordInputResult.currentServerMasterKeyHash;
request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash;
request.masterPasswordHint = passwordInputResult.newPasswordHint;
request.key = newMasterKeyEncryptedUserKey[1].encryptedString as string;
try {
await this.masterPasswordApiService.postPassword(request);
} catch {
throw new Error("Could not change password");
}
}
}

View File

@@ -1,6 +1,8 @@
import { Component, Inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { DIALOG_DATA, ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
export type FingerprintDialogData = {

View File

@@ -1,3 +1,5 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { svgIcon } from "@bitwarden/components";
export const BitwardenLogo = svgIcon`

View File

@@ -1,3 +1,5 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { svgIcon } from "@bitwarden/components";
export const BitwardenShield = svgIcon`

View File

@@ -1,3 +1,5 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { svgIcon } from "@bitwarden/components";
export const DeviceVerificationIcon = svgIcon`

View File

@@ -1,3 +1,5 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { svgIcon } from "@bitwarden/components";
export const DevicesIcon = svgIcon`

View File

@@ -1,3 +1,5 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { svgIcon } from "@bitwarden/components";
export const LockIcon = svgIcon`

View File

@@ -1,3 +1,5 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { svgIcon } from "@bitwarden/components";
export const RegistrationCheckEmailIcon = svgIcon`

View File

@@ -1,3 +1,5 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { svgIcon } from "@bitwarden/components";
export const RegistrationExpiredLinkIcon = svgIcon`

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