1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 11:01:17 +00:00

Merge branch 'main' into ps/PM-19479-sdk-state-traits

# Conflicts:
#	libs/common/src/platform/services/sdk/default-sdk.service.ts
This commit is contained in:
Daniel García
2025-06-08 18:19:29 +02:00
1425 changed files with 33362 additions and 22649 deletions

View File

@@ -1,6 +1,6 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("../shared/tsconfig.spec");
const { compilerOptions } = require("../../tsconfig.base");
const sharedConfig = require("../../libs/shared/jest.config.angular");
@@ -8,13 +8,12 @@ const sharedConfig = require("../../libs/shared/jest.config.angular");
module.exports = {
...sharedConfig,
displayName: "libs/angular tests",
preset: "jest-preset-angular",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(
// lets us use @bitwarden/common/spec in tests
{ "@bitwarden/common/spec": ["../common/spec"], ...(compilerOptions?.paths ?? {}) },
{ "@bitwarden/common/spec": ["libs/common/spec"], ...(compilerOptions?.paths ?? {}) },
{
prefix: "<rootDir>/",
prefix: "<rootDir>/../../",
},
),
};

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

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

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

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

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

View File

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

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

@@ -85,11 +85,11 @@ import { IconComponent } from "./vault/components/icon.component";
TextDragDirective,
CopyClickDirective,
A11yTitleDirective,
AutofocusDirective,
],
declarations: [
A11yInvalidDirective,
ApiActionDirective,
AutofocusDirective,
BoxRowDirective,
DeprecatedCalloutComponent,
CopyTextDirective,

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

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

@@ -43,12 +43,9 @@ on any component.
The persistence layer ensures that the popup will open at the same route as was active when it
closed, provided that none of the lifetime expiration events have occurred.
:::tip Excluding a route
If a particular route should be excluded from the history and not persisted, add
`doNotSaveUrl: true` to the `data` property on the route.
:::
> [!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
@@ -85,13 +82,10 @@ const mySignal = this.viewCacheService.signal({
mySignal.set("value")
```
:::note Equality comparison
By default, signals use `Object.is` to determine equality, and `set()` will only trigger updates if
the updated value is not equal to the current signal state. See documentation
[here](https://angular.dev/guide/signals#signal-equality-functions).
:::
> [!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:

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

View File

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

View File

@@ -3,12 +3,16 @@
import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { Subject } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
CollectionService,
DefaultCollectionService,
DefaultOrganizationUserApiService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
@@ -30,6 +34,8 @@ import {
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,
@@ -316,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,
@@ -1504,7 +1512,6 @@ const safeProviders: SafeProvider[] = [
StateProvider,
ApiServiceAbstraction,
OrganizationServiceAbstraction,
ConfigService,
AuthServiceAbstraction,
NotificationsService,
MessageListener,

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

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

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

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

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,70 @@
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),
);
// When the user has dismissed the nudge or spotlight, return the nudge status directly
if (nudgeStatus.hasBadgeDismissed || nudgeStatus.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: 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,6 @@
export * from "./account-security-nudge.service";
export * from "./has-items-nudge.service";
export * from "./empty-vault-nudge.service";
export * from "./vault-settings-import-nudge.service";
export * from "./new-item-nudge.service";
export * from "./new-account-nudge.service";

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 check if account is older than 30 days
*/
@Injectable({
providedIn: "root",
})
export class NewAccountNudgeService extends DefaultSingleNudgeService {
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([
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,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,
NewAccountNudgeService,
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, NewAccountNudgeService];
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: NewAccountNudgeService,
useValue: mock<NewAccountNudgeService>(),
},
{
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 { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
NewAccountNudgeService,
HasItemsNudgeService,
EmptyVaultNudgeService,
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
*/
export const NudgeType = {
/** Nudge to show when user has no items in their vault */
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",
} as const;
export type NudgeType = UnionOfValues<typeof NudgeType>;
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);
private newAcctNudgeService = inject(NewAccountNudgeService);
/**
* 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]: this.newAcctNudgeService,
[NudgeType.DownloadBitwarden]: this.newAcctNudgeService,
[NudgeType.GeneratorNudgeStatus]: this.newAcctNudgeService,
[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";

View File

@@ -1,27 +1,5 @@
{
"extends": "../shared/tsconfig",
"compilerOptions": {
"paths": {
"@bitwarden/admin-console/common": ["../admin-console/src/common"],
"@bitwarden/angular/*": ["../angular/src/*"],
"@bitwarden/auth/angular": ["../auth/src/angular"],
"@bitwarden/auth/common": ["../auth/src/common"],
"@bitwarden/common/*": ["../common/src/*"],
"@bitwarden/components": ["../components/src"],
"@bitwarden/generator-components": ["../tools/generator/components/src"],
"@bitwarden/generator-core": ["../tools/generator/core/src"],
"@bitwarden/generator-history": ["../tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"],
"@bitwarden/importer/core": ["../importer/src"],
"@bitwarden/importer-ui": ["../importer/src/components"],
"@bitwarden/key-management": ["../key-management/src"],
"@bitwarden/platform": ["../platform/src"],
"@bitwarden/ui-common": ["../ui/common/src"],
"@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"],
"@bitwarden/vault": ["../vault/src"]
}
},
"extends": "../../tsconfig.base",
"include": ["src", "spec"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,4 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"isolatedModules": true,
"emitDecoratorMetadata": false
},
"files": ["./test.setup.ts"]
}