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:
@@ -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>/../../",
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -27,6 +27,7 @@ const testStringFeatureValue = "test-value";
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
standalone: false,
|
||||
})
|
||||
class TestComponent {
|
||||
testBooleanFeature = testBooleanFeature;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Directive, HostListener, Input } from "@angular/core";
|
||||
|
||||
@Directive({
|
||||
selector: "[appTextDrag]",
|
||||
standalone: true,
|
||||
host: {
|
||||
draggable: "true",
|
||||
class: "tw-cursor-move",
|
||||
|
||||
@@ -85,11 +85,11 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
TextDragDirective,
|
||||
CopyClickDirective,
|
||||
A11yTitleDirective,
|
||||
AutofocusDirective,
|
||||
],
|
||||
declarations: [
|
||||
A11yInvalidDirective,
|
||||
ApiActionDirective,
|
||||
AutofocusDirective,
|
||||
BoxRowDirective,
|
||||
DeprecatedCalloutComponent,
|
||||
CopyTextDirective,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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> & {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -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
|
||||
|
||||
3
libs/angular/src/vault/index.ts
Normal file
3
libs/angular/src/vault/index.ts
Normal 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";
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
219
libs/angular/src/vault/services/nudges.service.spec.ts
Normal file
219
libs/angular/src/vault/services/nudges.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
177
libs/angular/src/vault/services/nudges.service.ts
Normal file
177
libs/angular/src/vault/services/nudges.service.ts
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"emitDecoratorMetadata": false
|
||||
},
|
||||
"files": ["./test.setup.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user