1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 14:04:03 +00:00

Merge branch 'main' into platform/pm-9388/add-duckduckgo-browser-device-type

This commit is contained in:
Todd Martin
2025-06-04 21:54:27 -04:00
committed by GitHub
1928 changed files with 47070 additions and 26806 deletions

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

@@ -11,7 +11,6 @@ import { ButtonModule } from "@bitwarden/components";
*/
@Component({
selector: "app-authentication-timeout",
standalone: true,
imports: [CommonModule, JslibModule, ButtonModule, RouterModule],
template: `
<p class="tw-text-center">

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,13 +5,15 @@ 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";
import { activeAuthGuard } from "./active-auth.guard";
@Component({ template: "" })
@Component({ template: "", standalone: false })
class EmptyComponent {}
describe("activeAuthGuard", () => {

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

@@ -44,7 +44,7 @@ describe("lockGuard", () => {
const keyService: MockProxy<KeyService> = mock<KeyService>();
keyService.isLegacyUser.mockResolvedValue(setupParams.isLegacyUser);
keyService.everHadUserKey$ = of(setupParams.everHadUserKey);
keyService.everHadUserKey$.mockReturnValue(of(setupParams.everHadUserKey));
const platformUtilService: MockProxy<PlatformUtilsService> = mock<PlatformUtilsService>();
platformUtilService.getClientType.mockReturnValue(setupParams.clientType);
@@ -79,7 +79,6 @@ describe("lockGuard", () => {
{ path: "", component: EmptyComponent },
{ path: "lock", component: EmptyComponent, canActivate: [lockGuard()] },
{ path: "non-lock-route", component: EmptyComponent },
{ path: "migrate-legacy-encryption", component: EmptyComponent },
]),
],
providers: [
@@ -182,18 +181,6 @@ describe("lockGuard", () => {
expect(messagingService.send).toHaveBeenCalledWith("logout");
});
it("should send the user to migrate-legacy-encryption if they are a legacy user on a web client", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
isLegacyUser: true,
clientType: ClientType.Web,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/migrate-legacy-encryption");
});
it("should allow navigation to the lock route when device trust is supported, the user has a MP, and the user is coming from the login-initiated page", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,

View File

@@ -11,11 +11,9 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ClientType } from "@bitwarden/common/enums";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { KeyService } from "@bitwarden/key-management";
/**
@@ -33,7 +31,6 @@ export function lockGuard(): CanActivateFn {
const authService = inject(AuthService);
const keyService = inject(KeyService);
const deviceTrustService = inject(DeviceTrustServiceAbstraction);
const platformUtilService = inject(PlatformUtilsService);
const messagingService = inject(MessagingService);
const router = inject(Router);
const userVerificationService = inject(UserVerificationService);
@@ -59,12 +56,7 @@ export function lockGuard(): CanActivateFn {
return false;
}
// If legacy user on web, redirect to migration page
if (await keyService.isLegacyUser()) {
if (platformUtilService.getClientType() === ClientType.Web) {
return router.createUrlTree(["migrate-legacy-encryption"]);
}
// Log out legacy users on other clients
messagingService.send("logout");
return false;
}
@@ -84,7 +76,7 @@ export function lockGuard(): CanActivateFn {
}
// If authN user with TDE directly navigates to lock, reject that navigation
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(activeUser.id));
if (tdeEnabled && !everHadUserKey) {
return false;
}

View File

@@ -2,8 +2,10 @@ import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { KeyService } from "@bitwarden/key-management";
@@ -33,6 +35,7 @@ export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActiv
const authService = inject(AuthService);
const keyService = inject(KeyService);
const deviceTrustService = inject(DeviceTrustServiceAbstraction);
const accountService = inject(AccountService);
const logService = inject(LogService);
const router = inject(Router);
@@ -49,7 +52,8 @@ export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActiv
// If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the
// login decryption options component.
const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
const userId = await firstValueFrom(accountService.activeAccount$.pipe(getUserId));
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(userId));
if (authStatus === AuthenticationStatus.Locked && tdeEnabled && !everHadUserKey) {
logService.info(
"Sending user to TDE decryption options. AuthStatus is %s. TDE support is %s. Ever had user key is %s.",

View File

@@ -0,0 +1,107 @@
import { TestBed } from "@angular/core/testing";
import { Router, provideRouter } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import { tdeDecryptionRequiredGuard } from "./tde-decryption-required.guard";
describe("tdeDecryptionRequiredGuard", () => {
const activeUser: Account = {
id: "fake_user_id" as UserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
};
const setup = (
activeUser: Account | null,
authStatus: AuthenticationStatus | null = null,
tdeEnabled: boolean = false,
everHadUserKey: boolean = false,
) => {
const accountService = mock<AccountService>();
const authService = mock<AuthService>();
const keyService = mock<KeyService>();
const deviceTrustService = mock<DeviceTrustServiceAbstraction>();
const logService = mock<LogService>();
accountService.activeAccount$ = new BehaviorSubject<Account | null>(activeUser);
if (authStatus !== null) {
authService.getAuthStatus.mockResolvedValue(authStatus);
}
keyService.everHadUserKey$.mockReturnValue(of(everHadUserKey));
deviceTrustService.supportsDeviceTrust$ = of(tdeEnabled);
const testBed = TestBed.configureTestingModule({
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: AuthService, useValue: authService },
{ provide: KeyService, useValue: keyService },
{ provide: DeviceTrustServiceAbstraction, useValue: deviceTrustService },
{ provide: LogService, useValue: logService },
provideRouter([
{ path: "", component: EmptyComponent },
{
path: "protected-route",
component: EmptyComponent,
canActivate: [tdeDecryptionRequiredGuard()],
},
]),
],
});
return {
router: testBed.inject(Router),
};
};
it("redirects to root when the active account is null", async () => {
const { router } = setup(null, null);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/");
});
test.each([AuthenticationStatus.Unlocked, AuthenticationStatus.LoggedOut])(
"redirects to root when the user isn't locked",
async (authStatus) => {
const { router } = setup(activeUser, authStatus);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/");
},
);
it("redirects to root when TDE is not enabled", async () => {
const { router } = setup(activeUser, AuthenticationStatus.Locked, false, true);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/");
});
it("redirects to root when user has had a user key", async () => {
const { router } = setup(activeUser, AuthenticationStatus.Locked, true, true);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/");
});
it("allows access when user is locked, TDE is enabled, and user has never had a user key", async () => {
const { router } = setup(activeUser, AuthenticationStatus.Locked, true, false);
const result = await router.navigate(["protected-route"]);
expect(result).toBe(true);
expect(router.url).toBe("/protected-route");
});
});

View File

@@ -5,8 +5,9 @@ import {
RouterStateSnapshot,
CanActivateFn,
} from "@angular/router";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
@@ -24,12 +25,18 @@ export function tdeDecryptionRequiredGuard(): CanActivateFn {
const authService = inject(AuthService);
const keyService = inject(KeyService);
const deviceTrustService = inject(DeviceTrustServiceAbstraction);
const accountService = inject(AccountService);
const logService = inject(LogService);
const router = inject(Router);
const userId = await firstValueFrom(accountService.activeAccount$.pipe(map((a) => a?.id)));
if (userId == null) {
return router.createUrlTree(["/"]);
}
const authStatus = await authService.getAuthStatus();
const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(userId));
// We need to determine if we should bypass the decryption options and send the user to the vault.
// The ONLY time that we want to send a user to the decryption options is when:

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 { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -43,7 +43,7 @@ describe("UnauthGuard", () => {
authService.authStatusFor$.mockReturnValue(activeAccountStatusObservable);
}
keyService.everHadUserKey$ = new BehaviorSubject<boolean>(everHadUserKey);
keyService.everHadUserKey$.mockReturnValue(of(everHadUserKey));
deviceTrustService.supportsDeviceTrustByUserId$.mockReturnValue(
new BehaviorSubject<boolean>(tdeEnabled),
);

View File

@@ -50,7 +50,7 @@ async function unauthGuard(
const tdeEnabled = await firstValueFrom(
deviceTrustService.supportsDeviceTrustByUserId$(activeUser.id),
);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(activeUser.id));
// If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the
// login decryption options component.

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

@@ -27,6 +27,7 @@ const testStringFeatureValue = "test-value";
</div>
</div>
`,
standalone: false,
})
class TestComponent {
testBooleanFeature = testBooleanFeature;

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

@@ -2,7 +2,6 @@ import { Directive, HostListener, Input } from "@angular/core";
@Directive({
selector: "[appTextDrag]",
standalone: true,
host: {
draggable: "true",
class: "tw-cursor-move",

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

@@ -2,7 +2,6 @@ import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
name: "pluralize",
standalone: true,
})
export class PluralizePipe implements PipeTransform {
transform(count: number, singular: string, plural: string): string {

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

@@ -12,7 +12,7 @@ import { I18nMockService, ToastService } from "@bitwarden/components/src";
import { canAccessFeature } from "./feature-flag.guard";
@Component({ template: "" })
@Component({ template: "", standalone: false })
export class EmptyComponent {}
describe("canAccessFeature", () => {

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,124 @@
# 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]
> 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]
> 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";
@@ -13,6 +15,7 @@ import {
import { Theme } from "@bitwarden/common/platform/enums";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Message } from "@bitwarden/common/platform/messaging";
import { HttpOperations } from "@bitwarden/common/services/api.service";
import { SafeInjectionToken } from "@bitwarden/ui-common";
// Re-export the SafeInjectionToken from ui-common
export { SafeInjectionToken } from "@bitwarden/ui-common";
@@ -61,3 +64,5 @@ export const REFRESH_ACCESS_TOKEN_ERROR_CALLBACK = new SafeInjectionToken<() =>
export const ENV_ADDITIONAL_REGIONS = new SafeInjectionToken<RegionConfig[]>(
"ENV_ADDITIONAL_REGIONS",
);
export const HTTP_OPERATIONS = new SafeInjectionToken<HttpOperations>("HTTP_OPERATIONS");

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,18 +337,20 @@ 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,
DEFAULT_VAULT_TIMEOUT,
ENV_ADDITIONAL_REGIONS,
HTTP_OPERATIONS,
INTRAPROCESS_MESSAGING_SUBJECT,
LOCALES_DIRECTORY,
LOCKED_CALLBACK,
@@ -507,6 +521,7 @@ const safeProviders: SafeProvider[] = [
stateProvider: StateProvider,
accountService: AccountServiceAbstraction,
logService: LogService,
cipherEncryptionService: CipherEncryptionService,
) =>
new CipherService(
keyService,
@@ -523,6 +538,7 @@ const safeProviders: SafeProvider[] = [
stateProvider,
accountService,
logService,
cipherEncryptionService,
),
deps: [
KeyService,
@@ -539,6 +555,7 @@ const safeProviders: SafeProvider[] = [
StateProvider,
AccountServiceAbstraction,
LogService,
CipherEncryptionService,
],
}),
safeProvider({
@@ -700,6 +717,10 @@ const safeProviders: SafeProvider[] = [
},
deps: [ToastService, I18nServiceAbstraction],
}),
safeProvider({
provide: HTTP_OPERATIONS,
useValue: { createRequest: (url, request) => new Request(url, request) },
}),
safeProvider({
provide: ApiServiceAbstraction,
useClass: ApiService,
@@ -712,6 +733,7 @@ const safeProviders: SafeProvider[] = [
LogService,
LOGOUT_CALLBACK,
VaultTimeoutSettingsService,
HTTP_OPERATIONS,
],
}),
safeProvider({
@@ -1072,7 +1094,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: OrganizationSponsorshipApiServiceAbstraction,
useClass: OrganizationSponsorshipApiService,
deps: [ApiServiceAbstraction],
deps: [ApiServiceAbstraction, PlatformUtilsServiceAbstraction],
}),
safeProvider({
provide: OrganizationBillingApiServiceAbstraction,
@@ -1489,7 +1511,6 @@ const safeProviders: SafeProvider[] = [
StateProvider,
ApiServiceAbstraction,
OrganizationServiceAbstraction,
ConfigService,
AuthServiceAbstraction,
NotificationsService,
MessageListener,
@@ -1521,6 +1542,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

@@ -20,7 +20,6 @@ type BackgroundTypes = "danger" | "primary" | "success" | "warning";
@Component({
selector: "tools-password-strength",
templateUrl: "password-strength-v2.component.html",
standalone: true,
imports: [CommonModule, JslibModule, ProgressModule],
})
export class PasswordStrengthV2Component implements OnChanges {

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";
@@ -24,11 +26,13 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
CipherService,
EncryptionContext,
} from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -40,6 +44,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 +275,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) {
@@ -738,17 +742,17 @@ export class AddEditComponent implements OnInit, OnDestroy {
return this.cipherService.encrypt(this.cipher, userId);
}
protected saveCipher(cipher: Cipher) {
protected saveCipher(data: EncryptionContext) {
let orgAdmin = this.organization?.canEditAllCiphers;
// if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection
if (!cipher.collectionIds) {
if (!data.cipher.collectionIds) {
orgAdmin = this.organization?.canEditUnassignedCiphers;
}
return this.cipher.id == null
? this.cipherService.createWithServer(cipher, orgAdmin)
: this.cipherService.updateWithServer(cipher, orgAdmin);
? this.cipherService.createWithServer(data, orgAdmin)
: this.cipherService.updateWithServer(data, orgAdmin);
}
protected deleteCipher(userId: UserId) {

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

@@ -0,0 +1,37 @@
<div
class="tw-rounded-2xl tw-bg-primary-100 tw-border-primary-600 tw-border-solid tw-border tw-p-4 tw-pt-3 tw-flex tw-flex-col tw-gap-2"
>
<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
*ngIf="subtitle"
class="tw-text-main tw-mb-0"
bitTypography="body2"
[innerHTML]="subtitle"
></p>
<ng-content *ngIf="!subtitle"></ng-content>
</div>
<button
type="button"
bitIconButton="bwi-close"
size="small"
*ngIf="!persistent"
(click)="handleDismiss()"
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
></button>
</div>
<button
class="tw-w-full"
bitButton
type="button"
buttonType="primary"
*ngIf="buttonText"
(click)="handleButtonClick($event)"
>
{{ buttonText }}
<i *ngIf="buttonIcon" [ngClass]="buttonIcon" class="bwi tw-ml-1" aria-hidden="true"></i>
</button>
</div>

View File

@@ -0,0 +1,33 @@
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { ButtonModule, IconButtonModule, TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@Component({
selector: "bit-spotlight",
templateUrl: "spotlight.component.html",
imports: [ButtonModule, CommonModule, IconButtonModule, I18nPipe, TypographyModule],
})
export class SpotlightComponent {
// The title of the component
@Input({ required: true }) title: string | null = null;
// The subtitle of the component
@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
@Input() persistent = false;
// Optional icon to display on the button
@Input() buttonIcon: string | null = null;
@Output() onDismiss = new EventEmitter<void>();
@Output() onButtonClick = new EventEmitter();
handleButtonClick(event: MouseEvent): void {
this.onButtonClick.emit(event);
}
handleDismiss(): void {
this.onDismiss.emit();
}
}

View File

@@ -0,0 +1,70 @@
import { moduleMetadata, Meta, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
ButtonModule,
I18nMockService,
IconButtonModule,
TypographyModule,
} from "@bitwarden/components";
import { SpotlightComponent } from "./spotlight.component";
const meta: Meta<SpotlightComponent> = {
title: "Vault/Spotlight",
component: SpotlightComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule, IconButtonModule, TypographyModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
close: "Close",
});
},
},
],
}),
],
args: {
title: "Primary",
subtitle: "Callout Text",
buttonText: "Button",
},
};
export default meta;
type Story = StoryObj<SpotlightComponent>;
export const Default: Story = {};
export const WithoutButton: Story = {
args: {
buttonText: undefined,
},
};
export const Persistent: Story = {
args: {
persistent: true,
},
};
export const WithButtonIcon: Story = {
args: {
buttonIcon: "bwi bwi-external-link",
},
render: (args) => ({
props: args,
template: `
<bit-spotlight
[title]="title"
[subtitle]="subtitle"
buttonText="External Link"
buttonIcon="bwi-external-link"
></bit-spotlight>
`,
}),
};

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

@@ -0,0 +1,47 @@
import { Injectable, inject } from "@angular/core";
import { Observable, combineLatest, from, map, of } 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 { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Custom Nudge Service to use for the Autofill Nudge in the Vault
*/
@Injectable({
providedIn: "root",
})
export class AutofillNudgeService extends DefaultSingleNudgeService {
vaultProfileService = inject(VaultProfileService);
logService = inject(LogService);
nudgeStatus$(_: 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([
profileDate$,
this.getNudgeStatus$(NudgeType.AutofillNudge, userId),
of(Date.now() - THIRTY_DAYS_MS),
]).pipe(
map(([profileCreationDate, status, profileCutoff]) => {
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
return {
hasBadgeDismissed: status.hasBadgeDismissed || profileOlderThanCutoff,
hasSpotlightDismissed: status.hasSpotlightDismissed || profileOlderThanCutoff,
};
}),
);
}
}

View File

@@ -0,0 +1,42 @@
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";
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 DownloadBitwardenNudgeService extends DefaultSingleNudgeService {
private vaultProfileService = inject(VaultProfileService);
private logService = inject(LogService);
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
return of(new Date());
}),
);
return combineLatest([
profileDate$,
this.getNudgeStatus$(nudgeType, userId),
of(Date.now() - THIRTY_DAYS_MS),
]).pipe(
map(([profileCreationDate, status, profileCutoff]) => {
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
return {
hasBadgeDismissed: status.hasBadgeDismissed || profileOlderThanCutoff,
hasSpotlightDismissed: status.hasSpotlightDismissed || profileOlderThanCutoff,
};
}),
);
}
}

View File

@@ -0,0 +1,66 @@
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, NudgeType } from "../nudges.service";
/**
* Custom Nudge Service Checking Nudge Status For Empty Vault
*/
@Injectable({
providedIn: "root",
})
export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
cipherService = inject(CipherService);
organizationService = inject(OrganizationService);
collectionService = inject(CollectionService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
this.organizationService.organizations$(userId),
this.collectionService.decryptedCollections$,
]).pipe(
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
const vaultHasContents = !(ciphers == null || ciphers.length === 0);
if (orgs == null || orgs.length === 0) {
return nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed
? of(nudgeStatus)
: of({
hasSpotlightDismissed: vaultHasContents,
hasBadgeDismissed: vaultHasContents,
});
}
const orgIds = new Set(orgs.map((org) => org.id));
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
const hasManageCollections = collections.some(
(c) => c.manage && orgIds.has(c.organizationId),
);
// Do not show nudge when
// user has previously dismissed nudge
// OR
// user belongs to an organization and cannot create collections || manage collections
if (
nudgeStatus.hasBadgeDismissed ||
nudgeStatus.hasSpotlightDismissed ||
hasManageCollections ||
canCreateCollections
) {
return of(nudgeStatus);
}
return of({
hasSpotlightDismissed: vaultHasContents,
hasBadgeDismissed: vaultHasContents,
});
}),
);
}
}

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

@@ -0,0 +1,7 @@
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";
export * from "./vault-settings-import-nudge.service";
export * from "./new-item-nudge.service";

View File

@@ -0,0 +1,65 @@
import { inject, Injectable } from "@angular/core";
import { combineLatest, Observable, switchMap } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
/**
* Custom Nudge Service Checking Nudge Status For Vault New Item Types
*/
@Injectable({
providedIn: "root",
})
export class NewItemNudgeService extends DefaultSingleNudgeService {
cipherService = inject(CipherService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
]).pipe(
switchMap(async ([nudgeStatus, ciphers]) => {
if (nudgeStatus.hasSpotlightDismissed) {
return nudgeStatus;
}
let currentType: CipherType;
switch (nudgeType) {
case NudgeType.NewLoginItemStatus:
currentType = CipherType.Login;
break;
case NudgeType.NewCardItemStatus:
currentType = CipherType.Card;
break;
case NudgeType.NewIdentityItemStatus:
currentType = CipherType.Identity;
break;
case NudgeType.NewNoteItemStatus:
currentType = CipherType.SecureNote;
break;
case NudgeType.NewSshItemStatus:
currentType = CipherType.SshKey;
break;
}
const ciphersBoolean = ciphers.some((cipher) => cipher.type === currentType);
if (ciphersBoolean) {
const dismissedStatus = {
hasSpotlightDismissed: true,
hasBadgeDismissed: true,
};
await this.setNudgeStatus(nudgeType, dismissedStatus, userId);
return dismissedStatus;
}
return nudgeStatus;
}),
);
}
}

View File

@@ -0,0 +1,74 @@
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, NudgeType } from "../nudges.service";
/**
* Custom Nudge Service for the vault settings import badge.
*/
@Injectable({
providedIn: "root",
})
export class VaultSettingsImportNudgeService extends DefaultSingleNudgeService {
cipherService = inject(CipherService);
organizationService = inject(OrganizationService);
collectionService = inject(CollectionService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
this.organizationService.organizations$(userId),
this.collectionService.decryptedCollections$,
]).pipe(
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
const vaultHasMoreThanOneItem = (ciphers?.length ?? 0) > 1;
const { hasBadgeDismissed, hasSpotlightDismissed } = nudgeStatus;
// When the user has no organizations, return the nudge status directly
if ((orgs?.length ?? 0) === 0) {
return hasBadgeDismissed || hasSpotlightDismissed
? of(nudgeStatus)
: of({
hasSpotlightDismissed: vaultHasMoreThanOneItem,
hasBadgeDismissed: vaultHasMoreThanOneItem,
});
}
const orgIds = new Set(orgs.map((org) => org.id));
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
const hasManageCollections = collections.some(
(c) => c.manage && orgIds.has(c.organizationId),
);
// When the user has dismissed the nudge or spotlight, return the nudge status directly
if (hasBadgeDismissed || hasSpotlightDismissed) {
return of(nudgeStatus);
}
// When the user belongs to an organization and cannot create collections or manage collections,
// hide the nudge and spotlight
if (!hasManageCollections && !canCreateCollections) {
return of({
hasSpotlightDismissed: true,
hasBadgeDismissed: true,
});
}
// Otherwise, return the nudge status based on the vault contents
return of({
hasSpotlightDismissed: vaultHasMoreThanOneItem,
hasBadgeDismissed: vaultHasMoreThanOneItem,
});
}),
);
}
}

View File

@@ -0,0 +1,49 @@
import { inject, Injectable } from "@angular/core";
import { map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { NudgeStatus, NUDGE_DISMISSED_DISK_KEY, NudgeType } from "./nudges.service";
/**
* Base interface for handling a nudge's status
*/
export interface SingleNudgeService {
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus>;
setNudgeStatus(nudgeType: NudgeType, newStatus: NudgeStatus, userId: UserId): Promise<void>;
}
/**
* Default implementation for nudges. Set and Show Nudge dismissed state
*/
@Injectable({
providedIn: "root",
})
export class DefaultSingleNudgeService implements SingleNudgeService {
stateProvider = inject(StateProvider);
protected getNudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return this.stateProvider
.getUser(userId, NUDGE_DISMISSED_DISK_KEY)
.state$.pipe(
map(
(nudges) =>
nudges?.[nudgeType] ?? { hasBadgeDismissed: false, hasSpotlightDismissed: false },
),
);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return this.getNudgeStatus$(nudgeType, userId);
}
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

@@ -0,0 +1,219 @@
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 "../../../../../libs/common/spec";
import {
HasItemsNudgeService,
EmptyVaultNudgeService,
DownloadBitwardenNudgeService,
VaultSettingsImportNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
import { NudgesService, NudgeType } from "./nudges.service";
describe("Vault Nudges Service", () => {
let fakeStateProvider: FakeStateProvider;
let testBed: TestBed;
const mockConfigService = {
getFeatureFlag$: jest.fn().mockReturnValue(of(true)),
getFeatureFlag: jest.fn().mockReturnValue(true),
};
const nudgeServices = [EmptyVaultNudgeService, DownloadBitwardenNudgeService];
beforeEach(async () => {
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
testBed = TestBed.configureTestingModule({
imports: [],
providers: [
{
provide: NudgesService,
},
{
provide: DefaultSingleNudgeService,
},
{
provide: StateProvider,
useValue: fakeStateProvider,
},
{ provide: ConfigService, useValue: mockConfigService },
{
provide: HasItemsNudgeService,
useValue: mock<HasItemsNudgeService>(),
},
{
provide: DownloadBitwardenNudgeService,
useValue: mock<DownloadBitwardenNudgeService>(),
},
{
provide: EmptyVaultNudgeService,
useValue: mock<EmptyVaultNudgeService>(),
},
{
provide: VaultSettingsImportNudgeService,
useValue: mock<VaultSettingsImportNudgeService>(),
},
{
provide: ApiService,
useValue: mock<ApiService>(),
},
{ provide: CipherService, useValue: mock<CipherService>() },
{ provide: LogService, useValue: mock<LogService>() },
{
provide: AccountService,
useValue: mock<AccountService>(),
},
{
provide: LogService,
useValue: mock<LogService>(),
},
{
provide: PinServiceAbstraction,
useValue: mock<PinServiceAbstraction>(),
},
{
provide: VaultTimeoutSettingsService,
useValue: mock<VaultTimeoutSettingsService>(),
},
],
});
});
describe("DefaultSingleNudgeService", () => {
it("should return hasSpotlightDismissed === true when EmptyVaultNudge dismissed is true", async () => {
const service = testBed.inject(DefaultSingleNudgeService);
await service.setNudgeStatus(
NudgeType.EmptyVaultNudge,
{ hasBadgeDismissed: true, hasSpotlightDismissed: true },
"user-id" as UserId,
);
const result = await firstValueFrom(
service.nudgeStatus$(NudgeType.EmptyVaultNudge, "user-id" as UserId),
);
expect(result).toEqual({ hasBadgeDismissed: true, hasSpotlightDismissed: true });
});
it("should return hasSpotlightDismissed === true when EmptyVaultNudge dismissed is false", async () => {
const service = testBed.inject(DefaultSingleNudgeService);
await service.setNudgeStatus(
NudgeType.EmptyVaultNudge,
{ hasBadgeDismissed: false, hasSpotlightDismissed: false },
"user-id" as UserId,
);
const result = await firstValueFrom(
service.nudgeStatus$(NudgeType.EmptyVaultNudge, "user-id" as UserId),
);
expect(result).toEqual({ hasBadgeDismissed: false, hasSpotlightDismissed: false });
});
});
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(NudgesService);
const result = await firstValueFrom(
service.showNudgeStatus$(NudgeType.HasVaultItems, "user-id" as UserId),
);
expect(result).toBe(true);
});
it("should return false, the proper value for the custom nudge service nudgeStatus$", async () => {
TestBed.overrideProvider(HasItemsNudgeService, {
useValue: { nudgeStatus$: () => of(false) },
});
const service = testBed.inject(NudgesService);
const result = await firstValueFrom(
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);
});
});
describe("HasActiveBadges", () => {
it("should return true if a nudgeType with hasBadgeDismissed === false", async () => {
nudgeServices.forEach((service) => {
TestBed.overrideProvider(service, {
useValue: {
nudgeStatus$: () => of({ hasBadgeDismissed: false, hasSpotlightDismissed: false }),
},
});
});
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 () => {
nudgeServices.forEach((service) => {
TestBed.overrideProvider(service, {
useValue: {
nudgeStatus$: () => of({ hasBadgeDismissed: true, hasSpotlightDismissed: false }),
},
});
});
const service = testBed.inject(NudgesService);
const result = await firstValueFrom(service.hasActiveBadges$("user-id" as UserId));
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,177 @@
import { inject, Injectable } from "@angular/core";
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, NUDGES_DISK } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import {
HasItemsNudgeService,
EmptyVaultNudgeService,
AutofillNudgeService,
DownloadBitwardenNudgeService,
NewItemNudgeService,
AccountSecurityNudgeService,
VaultSettingsImportNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
export type NudgeStatus = {
hasBadgeDismissed: boolean;
hasSpotlightDismissed: boolean;
};
/**
* Enum to list the various nudge types, to be used by components/badges to show/hide the nudge
*/
// 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",
VaultSettingsImportNudge = "vault-settings-import-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",
GeneratorNudgeStatus = "generator-nudge-status",
}
export const NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
Partial<Record<NudgeType, NudgeStatus>>
>(NUDGES_DISK, "vaultNudgeDismissed", {
deserializer: (nudge) => nudge,
clearOn: [], // Do not clear dismissals
});
@Injectable({
providedIn: "root",
})
export class NudgesService {
private newItemNudgeService = inject(NewItemNudgeService);
/**
* Custom nudge services to use for specific nudge types
* Each nudge type can have its own service to determine when to show the nudge
* @private
*/
private customNudgeServices: Partial<Record<NudgeType, SingleNudgeService>> = {
[NudgeType.HasVaultItems]: inject(HasItemsNudgeService),
[NudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService),
[NudgeType.VaultSettingsImportNudge]: inject(VaultSettingsImportNudgeService),
[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,
};
/**
* Default nudge service to use when no custom service is available
* Simply stores the dismissed state in the user's state
* @private
*/
private defaultNudgeService = inject(DefaultSingleNudgeService);
private configService = inject(ConfigService);
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
*/
showNudgeStatus$(nudge: NudgeType, userId: UserId) {
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
switchMap((hasVaultNudgeFlag) => {
if (!hasVaultNudgeFlag) {
return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true } as NudgeStatus);
}
return this.getNudgeService(nudge).nudgeStatus$(nudge, userId);
}),
);
}
/**
* Dismiss a nudge for the user so that it is not shown again
* @param nudge
* @param userId
*/
async dismissNudge(nudge: NudgeType, userId: UserId, onlyBadge: boolean = false) {
const dismissedStatus = onlyBadge
? { hasBadgeDismissed: true, hasSpotlightDismissed: false }
: { hasBadgeDismissed: true, hasSpotlightDismissed: true };
await this.getNudgeService(nudge).setNudgeStatus(nudge, dismissedStatus, userId);
}
/**
* Check if there are any active badges for the user to show Berry notification in Tabs
* @param userId
*/
hasActiveBadges$(userId: UserId): Observable<boolean> {
// Add more nudge types here if they have the settings badge feature
const nudgeTypes = [NudgeType.EmptyVaultNudge, NudgeType.DownloadBitwarden];
const nudgeTypesWithBadge$ = nudgeTypes.map((nudge) => {
return this.getNudgeService(nudge)
.nudgeStatus$(nudge, userId)
.pipe(
map((status) => !status?.hasBadgeDismissed),
shareReplay({ refCount: false, bufferSize: 1 }),
);
});
return combineLatest(nudgeTypesWithBadge$).pipe(
map((results) => results.some((result) => result === true)),
);
}
}

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