mirror of
https://github.com/bitwarden/browser
synced 2026-02-22 04:14:04 +00:00
Merge remote-tracking branch 'origin' into innovation/archive/desktop-work
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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,6 +5,8 @@ import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
|
||||
import {
|
||||
@@ -30,9 +30,7 @@ describe("AuthGuard", () => {
|
||||
authService.getAuthStatus.mockResolvedValue(authStatus);
|
||||
const messagingService: MockProxy<MessagingService> = mock<MessagingService>();
|
||||
const keyConnectorService: MockProxy<KeyConnectorService> = mock<KeyConnectorService>();
|
||||
keyConnectorService.getConvertAccountRequired.mockResolvedValue(
|
||||
keyConnectorServiceRequiresAccountConversion,
|
||||
);
|
||||
keyConnectorService.convertAccountRequired$ = of(keyConnectorServiceRequiresAccountConversion);
|
||||
const accountService: MockProxy<AccountService> = mock<AccountService>();
|
||||
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
|
||||
accountService.activeAccount$ = activeAccountSubject;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const authGuard: CanActivateFn = async (
|
||||
|
||||
if (
|
||||
!routerState.url.includes("remove-password") &&
|
||||
(await keyConnectorService.getConvertAccountRequired())
|
||||
(await firstValueFrom(keyConnectorService.convertAccountRequired$))
|
||||
) {
|
||||
return router.createUrlTree(["/remove-password"]);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -65,12 +65,8 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
validate(): boolean {
|
||||
if (this.formGroup.dirty) {
|
||||
this.formGroup.markAllAsTouched();
|
||||
return this.formGroup.valid;
|
||||
} else {
|
||||
return this.formGroup.valid;
|
||||
}
|
||||
this.formGroup.markAllAsTouched();
|
||||
return this.formGroup.valid;
|
||||
}
|
||||
|
||||
markAllAsTouched() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -23,6 +23,12 @@ type BaseCacheOptions<T> = {
|
||||
* Optional flag to persist the cached value between navigation events.
|
||||
*/
|
||||
persistNavigation?: boolean;
|
||||
|
||||
/**
|
||||
* When set, the cached value will be cleared when the user changes tabs.
|
||||
* @optional
|
||||
*/
|
||||
clearOnTabChange?: true;
|
||||
} & (T extends JsonValue ? Deserializer<T> : Required<Deserializer<T>>);
|
||||
|
||||
export type SignalCacheOptions<T> = BaseCacheOptions<T> & {
|
||||
@@ -42,7 +48,7 @@ export type FormCacheOptions<TFormGroup extends FormGroup> = BaseCacheOptions<
|
||||
/**
|
||||
* Cache for temporary component state
|
||||
*
|
||||
* [Read more](./view-cache.md)
|
||||
* [Read more](./README.md)
|
||||
*
|
||||
* #### Implementations
|
||||
* - browser extension popup: used to persist UI between popup open and close
|
||||
|
||||
@@ -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,
|
||||
@@ -27,7 +31,11 @@ import {
|
||||
TwoFactorAuthComponentService,
|
||||
TwoFactorAuthEmailComponentService,
|
||||
TwoFactorAuthWebAuthnComponentService,
|
||||
ChangePasswordService,
|
||||
DefaultChangePasswordService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
AuthRequestApiService,
|
||||
AuthRequestService,
|
||||
@@ -314,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,
|
||||
@@ -1538,6 +1548,15 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultCipherEncryptionService,
|
||||
deps: [SdkService, LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ChangePasswordService,
|
||||
useClass: DefaultChangePasswordService,
|
||||
deps: [
|
||||
KeyService,
|
||||
MasterPasswordApiServiceAbstraction,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
@@ -40,6 +42,8 @@ import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { generate_ssh_key } from "@bitwarden/sdk-internal";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault";
|
||||
|
||||
@Directive()
|
||||
|
||||
@@ -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,34 @@
|
||||
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",
|
||||
standalone: true,
|
||||
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";
|
||||
@@ -97,7 +99,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,47 @@
|
||||
import { Injectable, inject } from "@angular/core";
|
||||
import { Observable, combineLatest, from, map, of } from "rxjs";
|
||||
import { catchError } from "rxjs/operators";
|
||||
|
||||
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||
import { NudgeStatus, NudgeType } from "../nudges.service";
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Custom Nudge Service to use for the Autofill Nudge in the Vault
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class AutofillNudgeService extends DefaultSingleNudgeService {
|
||||
vaultProfileService = inject(VaultProfileService);
|
||||
logService = inject(LogService);
|
||||
|
||||
nudgeStatus$(_: NudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
|
||||
catchError(() => {
|
||||
this.logService.error("Error getting profile creation date");
|
||||
// Default to today to ensure we show the nudge
|
||||
return of(new Date());
|
||||
}),
|
||||
);
|
||||
|
||||
return combineLatest([
|
||||
profileDate$,
|
||||
this.getNudgeStatus$(NudgeType.AutofillNudge, userId),
|
||||
of(Date.now() - THIRTY_DAYS_MS),
|
||||
]).pipe(
|
||||
map(([profileCreationDate, status, profileCutoff]) => {
|
||||
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
|
||||
return {
|
||||
hasBadgeDismissed: status.hasBadgeDismissed || profileOlderThanCutoff,
|
||||
hasSpotlightDismissed: status.hasSpotlightDismissed || profileOlderThanCutoff,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable, inject } from "@angular/core";
|
||||
import { Observable, combineLatest, from, of } from "rxjs";
|
||||
import { catchError, map } from "rxjs/operators";
|
||||
|
||||
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||
import { NudgeStatus, NudgeType } from "../nudges.service";
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class DownloadBitwardenNudgeService extends DefaultSingleNudgeService {
|
||||
private vaultProfileService = inject(VaultProfileService);
|
||||
private logService = inject(LogService);
|
||||
|
||||
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
|
||||
catchError(() => {
|
||||
this.logService.error("Failed to load profile date:");
|
||||
// Default to today to ensure the nudge is shown
|
||||
return of(new Date());
|
||||
}),
|
||||
);
|
||||
|
||||
return combineLatest([
|
||||
profileDate$,
|
||||
this.getNudgeStatus$(nudgeType, userId),
|
||||
of(Date.now() - THIRTY_DAYS_MS),
|
||||
]).pipe(
|
||||
map(([profileCreationDate, status, profileCutoff]) => {
|
||||
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
|
||||
return {
|
||||
hasBadgeDismissed: status.hasBadgeDismissed || profileOlderThanCutoff,
|
||||
hasSpotlightDismissed: status.hasSpotlightDismissed || profileOlderThanCutoff,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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 filteredCiphers = ciphers?.filter((cipher) => {
|
||||
return cipher.deletedDate == null;
|
||||
});
|
||||
const vaultHasContents = !(filteredCiphers == null || filteredCiphers.length === 0);
|
||||
if (orgs == null || orgs.length === 0) {
|
||||
return nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed
|
||||
? of(nudgeStatus)
|
||||
: of({
|
||||
hasSpotlightDismissed: vaultHasContents,
|
||||
hasBadgeDismissed: vaultHasContents,
|
||||
});
|
||||
}
|
||||
const orgIds = new Set(orgs.map((org) => org.id));
|
||||
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
|
||||
const hasManageCollections = collections.some(
|
||||
(c) => c.manage && orgIds.has(c.organizationId),
|
||||
);
|
||||
// Do not show nudge when
|
||||
// user has previously dismissed nudge
|
||||
// OR
|
||||
// user belongs to an organization and cannot create collections || manage collections
|
||||
if (
|
||||
nudgeStatus.hasBadgeDismissed ||
|
||||
nudgeStatus.hasSpotlightDismissed ||
|
||||
hasManageCollections ||
|
||||
canCreateCollections
|
||||
) {
|
||||
return of(nudgeStatus);
|
||||
}
|
||||
return of({
|
||||
hasSpotlightDismissed: vaultHasContents,
|
||||
hasBadgeDismissed: vaultHasContents,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 "./autofill-nudge.service";
|
||||
export * from "./account-security-nudge.service";
|
||||
export * from "./has-items-nudge.service";
|
||||
export * from "./download-bitwarden-nudge.service";
|
||||
export * from "./empty-vault-nudge.service";
|
||||
export * from "./new-item-nudge.service";
|
||||
@@ -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,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;
|
||||
});
|
||||
}
|
||||
}
|
||||
214
libs/angular/src/vault/services/nudges.service.spec.ts
Normal file
214
libs/angular/src/vault/services/nudges.service.spec.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../libs/common/spec";
|
||||
|
||||
import {
|
||||
HasItemsNudgeService,
|
||||
EmptyVaultNudgeService,
|
||||
DownloadBitwardenNudgeService,
|
||||
} from "./custom-nudges-services";
|
||||
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
|
||||
import { NudgesService, NudgeType } from "./nudges.service";
|
||||
|
||||
describe("Vault Nudges Service", () => {
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
|
||||
let testBed: TestBed;
|
||||
const mockConfigService = {
|
||||
getFeatureFlag$: jest.fn().mockReturnValue(of(true)),
|
||||
getFeatureFlag: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
const nudgeServices = [EmptyVaultNudgeService, DownloadBitwardenNudgeService];
|
||||
|
||||
beforeEach(async () => {
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||
|
||||
testBed = TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
providers: [
|
||||
{
|
||||
provide: NudgesService,
|
||||
},
|
||||
{
|
||||
provide: DefaultSingleNudgeService,
|
||||
},
|
||||
{
|
||||
provide: StateProvider,
|
||||
useValue: fakeStateProvider,
|
||||
},
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{
|
||||
provide: HasItemsNudgeService,
|
||||
useValue: mock<HasItemsNudgeService>(),
|
||||
},
|
||||
{
|
||||
provide: DownloadBitwardenNudgeService,
|
||||
useValue: mock<DownloadBitwardenNudgeService>(),
|
||||
},
|
||||
{
|
||||
provide: EmptyVaultNudgeService,
|
||||
useValue: mock<EmptyVaultNudgeService>(),
|
||||
},
|
||||
{
|
||||
provide: 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
175
libs/angular/src/vault/services/nudges.service.ts
Normal file
175
libs/angular/src/vault/services/nudges.service.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { combineLatest, map, Observable, of, shareReplay, switchMap } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { UserKeyDefinition, NUDGES_DISK } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import {
|
||||
HasItemsNudgeService,
|
||||
EmptyVaultNudgeService,
|
||||
AutofillNudgeService,
|
||||
DownloadBitwardenNudgeService,
|
||||
NewItemNudgeService,
|
||||
AccountSecurityNudgeService,
|
||||
} from "./custom-nudges-services";
|
||||
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
|
||||
|
||||
export type NudgeStatus = {
|
||||
hasBadgeDismissed: boolean;
|
||||
hasSpotlightDismissed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum to list the various nudge types, to be used by components/badges to show/hide the nudge
|
||||
*/
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum NudgeType {
|
||||
/** Nudge to show when user has no items in their vault
|
||||
* Add future nudges here
|
||||
*/
|
||||
EmptyVaultNudge = "empty-vault-nudge",
|
||||
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",
|
||||
SendNudgeStatus = "send-nudge-status",
|
||||
}
|
||||
|
||||
export const NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
|
||||
Partial<Record<NudgeType, NudgeStatus>>
|
||||
>(NUDGES_DISK, "vaultNudgeDismissed", {
|
||||
deserializer: (nudge) => nudge,
|
||||
clearOn: [], // Do not clear dismissals
|
||||
});
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class NudgesService {
|
||||
private newItemNudgeService = inject(NewItemNudgeService);
|
||||
|
||||
/**
|
||||
* Custom nudge services to use for specific nudge types
|
||||
* Each nudge type can have its own service to determine when to show the nudge
|
||||
* @private
|
||||
*/
|
||||
private customNudgeServices: Partial<Record<NudgeType, SingleNudgeService>> = {
|
||||
[NudgeType.HasVaultItems]: inject(HasItemsNudgeService),
|
||||
[NudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService),
|
||||
[NudgeType.AccountSecurity]: inject(AccountSecurityNudgeService),
|
||||
[NudgeType.AutofillNudge]: inject(AutofillNudgeService),
|
||||
[NudgeType.DownloadBitwarden]: inject(DownloadBitwardenNudgeService),
|
||||
[NudgeType.NewLoginItemStatus]: this.newItemNudgeService,
|
||||
[NudgeType.NewCardItemStatus]: this.newItemNudgeService,
|
||||
[NudgeType.NewIdentityItemStatus]: this.newItemNudgeService,
|
||||
[NudgeType.NewNoteItemStatus]: this.newItemNudgeService,
|
||||
[NudgeType.NewSshItemStatus]: this.newItemNudgeService,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default nudge service to use when no custom service is available
|
||||
* Simply stores the dismissed state in the user's state
|
||||
* @private
|
||||
*/
|
||||
private defaultNudgeService = inject(DefaultSingleNudgeService);
|
||||
private configService = inject(ConfigService);
|
||||
|
||||
private getNudgeService(nudge: NudgeType): SingleNudgeService {
|
||||
return this.customNudgeServices[nudge] ?? this.defaultNudgeService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a nudge Spotlight should be shown to the user
|
||||
* @param nudge
|
||||
* @param userId
|
||||
*/
|
||||
showNudgeSpotlight$(nudge: NudgeType, userId: UserId): Observable<boolean> {
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
|
||||
switchMap((hasVaultNudgeFlag) => {
|
||||
if (!hasVaultNudgeFlag) {
|
||||
return of(false);
|
||||
}
|
||||
return this.getNudgeService(nudge)
|
||||
.nudgeStatus$(nudge, userId)
|
||||
.pipe(map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a nudge Badge should be shown to the user
|
||||
* @param nudge
|
||||
* @param userId
|
||||
*/
|
||||
showNudgeBadge$(nudge: NudgeType, userId: UserId): Observable<boolean> {
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
|
||||
switchMap((hasVaultNudgeFlag) => {
|
||||
if (!hasVaultNudgeFlag) {
|
||||
return of(false);
|
||||
}
|
||||
return this.getNudgeService(nudge)
|
||||
.nudgeStatus$(nudge, userId)
|
||||
.pipe(map((nudgeStatus) => !nudgeStatus.hasBadgeDismissed));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a nudge should be shown to the user
|
||||
* @param nudge
|
||||
* @param userId
|
||||
*/
|
||||
showNudgeStatus$(nudge: NudgeType, userId: UserId) {
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
|
||||
switchMap((hasVaultNudgeFlag) => {
|
||||
if (!hasVaultNudgeFlag) {
|
||||
return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true } as NudgeStatus);
|
||||
}
|
||||
return this.getNudgeService(nudge).nudgeStatus$(nudge, userId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a nudge for the user so that it is not shown again
|
||||
* @param nudge
|
||||
* @param userId
|
||||
*/
|
||||
async dismissNudge(nudge: NudgeType, userId: UserId, onlyBadge: boolean = false) {
|
||||
const dismissedStatus = onlyBadge
|
||||
? { hasBadgeDismissed: true, hasSpotlightDismissed: false }
|
||||
: { hasBadgeDismissed: true, hasSpotlightDismissed: true };
|
||||
await this.getNudgeService(nudge).setNudgeStatus(nudge, dismissedStatus, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any active badges for the user to show Berry notification in Tabs
|
||||
* @param userId
|
||||
*/
|
||||
hasActiveBadges$(userId: UserId): Observable<boolean> {
|
||||
// Add more nudge types here if they have the settings badge feature
|
||||
const nudgeTypes = [NudgeType.EmptyVaultNudge, NudgeType.DownloadBitwarden];
|
||||
|
||||
const nudgeTypesWithBadge$ = nudgeTypes.map((nudge) => {
|
||||
return this.getNudgeService(nudge)
|
||||
.nudgeStatus$(nudge, userId)
|
||||
.pipe(
|
||||
map((status) => !status?.hasBadgeDismissed),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
return combineLatest(nudgeTypesWithBadge$).pipe(
|
||||
map((results) => results.some((result) => result === true)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user