1
0
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:
Patrick Pimentel
2025-05-30 11:12:42 -04:00
849 changed files with 26487 additions and 17139 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

@@ -3,6 +3,8 @@
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";

View File

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

View File

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

View File

@@ -3,12 +3,16 @@
import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { Subject } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
CollectionService,
DefaultCollectionService,
DefaultOrganizationUserApiService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
@@ -27,7 +31,11 @@ import {
TwoFactorAuthComponentService,
TwoFactorAuthEmailComponentService,
TwoFactorAuthWebAuthnComponentService,
ChangePasswordService,
DefaultChangePasswordService,
} from "@bitwarden/auth/angular";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AuthRequestApiService,
AuthRequestService,
@@ -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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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();
}
}

View File

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

View File

@@ -54,6 +54,8 @@ import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cip
import { TotpInfo } from "@bitwarden/common/vault/services/totp.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PasswordRepromptService } from "@bitwarden/vault";
const BroadcasterSubscriptionId = "BaseViewComponent";
@@ -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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
import { Injectable, inject } from "@angular/core";
import { Observable, combineLatest, from, map, of } from "rxjs";
import { catchError } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Custom Nudge Service to use for the Autofill Nudge in the Vault
*/
@Injectable({
providedIn: "root",
})
export class AutofillNudgeService extends DefaultSingleNudgeService {
vaultProfileService = inject(VaultProfileService);
logService = inject(LogService);
nudgeStatus$(_: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
catchError(() => {
this.logService.error("Error getting profile creation date");
// Default to today to ensure we show the nudge
return of(new Date());
}),
);
return combineLatest([
profileDate$,
this.getNudgeStatus$(NudgeType.AutofillNudge, userId),
of(Date.now() - THIRTY_DAYS_MS),
]).pipe(
map(([profileCreationDate, status, profileCutoff]) => {
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
return {
hasBadgeDismissed: status.hasBadgeDismissed || profileOlderThanCutoff,
hasSpotlightDismissed: status.hasSpotlightDismissed || profileOlderThanCutoff,
};
}),
);
}
}

View File

@@ -0,0 +1,42 @@
import { Injectable, inject } from "@angular/core";
import { Observable, combineLatest, from, of } from "rxjs";
import { catchError, map } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
@Injectable({ providedIn: "root" })
export class DownloadBitwardenNudgeService extends DefaultSingleNudgeService {
private vaultProfileService = inject(VaultProfileService);
private logService = inject(LogService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
catchError(() => {
this.logService.error("Failed to load profile date:");
// Default to today to ensure the nudge is shown
return of(new Date());
}),
);
return combineLatest([
profileDate$,
this.getNudgeStatus$(nudgeType, userId),
of(Date.now() - THIRTY_DAYS_MS),
]).pipe(
map(([profileCreationDate, status, profileCutoff]) => {
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
return {
hasBadgeDismissed: status.hasBadgeDismissed || profileOlderThanCutoff,
hasSpotlightDismissed: status.hasSpotlightDismissed || profileOlderThanCutoff,
};
}),
);
}
}

View File

@@ -0,0 +1,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,
});
}),
);
}
}

View File

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

View File

@@ -0,0 +1,6 @@
export * from "./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";

View File

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

View File

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

View File

@@ -0,0 +1,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);
});
});
});

View 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)),
);
}
}

View File

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

View File

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

View File

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