diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 8698315b57c..7eec2804ece 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2363,6 +2363,70 @@ "autofillBlockedNoticeGuidance": { "message": "Change this in settings" }, + "change": { + "message": "Change" + }, + "changeButtonTitle": { + "message": "Change password - $ITEMNAME$", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "atRiskPasswords": { + "message": "At-risk passwords" + }, + "atRiskPasswordsDescSingleOrg": { + "message": "$ORGANIZATION$ is requesting you change the $COUNT$ passwords because they are at risk.", + "placeholders": { + "organization": { + "content": "$1", + "example": "Acme Corp" + }, + "count": { + "content": "$2", + "example": "2" + } + } + }, + "atRiskPasswordsDescMultiOrg": { + "message": "Your organizations are requesting you change the $COUNT$ passwords because they are at risk.", + "placeholders": { + "count": { + "content": "$1", + "example": "2" + } + } + }, + "reviewAndChangeAtRiskPassword": { + "message": "Review and change one at-risk password" + }, + "reviewAndChangeAtRiskPasswordsPlural": { + "message": "Review and change $COUNT$ at-risk passwords", + "placeholders": { + "count": { + "content": "$1", + "example": "2" + } + } + }, + "changeAtRiskPasswordsFaster": { + "message": "Change at-risk passwords faster" + }, + "changeAtRiskPasswordsFasterDesc": { + "message": "Update your settings so you can quickly autofill your passwords and generate new ones" + }, + "turnOnAutofill": { + "message": "Turn on autofill" + }, + "turnedOnAutofill": { + "message": "Turned on autofill" + }, + "dismiss": { + "message": "Dismiss" + }, "websiteItemLabel": { "message": "Website $number$ (URI)", "placeholders": { diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index d55a64a64eb..4dca29ee914 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -10,9 +10,9 @@ import { import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect"; import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { + activeAuthGuard, authGuard, lockGuard, - activeAuthGuard, redirectGuard, tdeDecryptionRequiredGuard, unauthGuardFn, @@ -23,10 +23,14 @@ import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guard import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, - LoginComponent, - LoginSecondaryContentComponent, + DevicesIcon, + DeviceVerificationIcon, LockIcon, + LoginComponent, + LoginDecryptionOptionsComponent, + LoginSecondaryContentComponent, LoginViaAuthRequestComponent, + NewDeviceVerificationComponent, PasswordHintComponent, RegistrationFinishComponent, RegistrationLockAltIcon, @@ -35,14 +39,10 @@ import { RegistrationStartSecondaryComponentData, RegistrationUserAddIcon, SetPasswordJitComponent, - UserLockIcon, - VaultIcon, - LoginDecryptionOptionsComponent, - DevicesIcon, SsoComponent, TwoFactorTimeoutIcon, - NewDeviceVerificationComponent, - DeviceVerificationIcon, + UserLockIcon, + VaultIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LockComponent } from "@bitwarden/key-management-ui"; @@ -90,7 +90,9 @@ import { MoreFromBitwardenPageV2Component } from "../tools/popup/settings/about- import { ExportBrowserV2Component } from "../tools/popup/settings/export/export-browser-v2.component"; import { ImportBrowserV2Component } from "../tools/popup/settings/import/import-browser-v2.component"; import { SettingsV2Component } from "../tools/popup/settings/settings-v2.component"; +import { canAccessAtRiskPasswords } from "../vault/guards/at-risk-passwords.guard"; import { clearVaultStateGuard } from "../vault/guards/clear-vault-state.guard"; +import { AtRiskPasswordsComponent } from "../vault/popup/components/at-risk-passwords/at-risk-passwords.component"; import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component"; import { AssignCollections } from "../vault/popup/components/vault-v2/assign-collections/assign-collections.component"; import { AttachmentsV2Component } from "../vault/popup/components/vault-v2/attachments/attachments-v2.component"; @@ -752,6 +754,11 @@ const routes: Routes = [ }, ], }, + { + path: "at-risk-passwords", + component: AtRiskPasswordsComponent, + canActivate: [authGuard, canAccessAtRiskPasswords], + }, { path: "account-switcher", component: AccountSwitcherComponent, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 257497fb13d..210a05d9947 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; import { Router } from "@angular/router"; -import { Subject, merge, of } from "rxjs"; +import { merge, of, Subject } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service"; @@ -11,21 +11,21 @@ import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/sa import { CLIENT_TYPE, DEFAULT_VAULT_TIMEOUT, + ENV_ADDITIONAL_REGIONS, INTRAPROCESS_MESSAGING_SUBJECT, MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, OBSERVABLE_MEMORY_STORAGE, + SafeInjectionToken, SECURE_STORAGE, SYSTEM_THEME_OBSERVABLE, - SafeInjectionToken, - ENV_ADDITIONAL_REGIONS, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { AnonLayoutWrapperDataService, LoginComponentService, - SsoComponentService, LoginDecryptionOptionsService, + SsoComponentService, } from "@bitwarden/auth/angular"; import { LockService, LoginEmailService, PinServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -111,10 +111,10 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { CompactModeService, DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { - KdfConfigService, - KeyService, BiometricsService, DefaultKeyService, + KdfConfigService, + KeyService, } from "@bitwarden/key-management"; import { LockComponentService } from "@bitwarden/key-management-ui"; import { PasswordRepromptService } from "@bitwarden/vault"; diff --git a/apps/browser/src/vault/guards/at-risk-passwords.guard.ts b/apps/browser/src/vault/guards/at-risk-passwords.guard.ts new file mode 100644 index 00000000000..ee991c81239 --- /dev/null +++ b/apps/browser/src/vault/guards/at-risk-passwords.guard.ts @@ -0,0 +1,29 @@ +import { inject } from "@angular/core"; +import { CanActivateFn } from "@angular/router"; +import { switchMap, tap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ToastService } from "@bitwarden/components"; +import { filterOutNullish, TaskService } from "@bitwarden/vault"; + +export const canAccessAtRiskPasswords: CanActivateFn = () => { + const accountService = inject(AccountService); + const taskService = inject(TaskService); + const toastService = inject(ToastService); + const i18nService = inject(I18nService); + + return accountService.activeAccount$.pipe( + filterOutNullish(), + switchMap((user) => taskService.tasksEnabled$(user.id)), + tap((tasksEnabled) => { + if (!tasksEnabled) { + toastService.showToast({ + variant: "error", + title: "", + message: i18nService.t("accessDenied"), + }); + } + }), + ); +}; diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html new file mode 100644 index 00000000000..16d9b6a322a --- /dev/null +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html @@ -0,0 +1,9 @@ + + + + {{ + (taskCount === 1 ? "reviewAndChangeAtRiskPassword" : "reviewAndChangeAtRiskPasswordsPlural") + | i18n: taskCount.toString() + }} + + diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts new file mode 100644 index 00000000000..5e46f3cd3d9 --- /dev/null +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts @@ -0,0 +1,32 @@ +import { CommonModule } from "@angular/common"; +import { Component, inject } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { map, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { filterOutNullish, SecurityTaskType, TaskService } from "@bitwarden/vault"; + +// TODO: This component will need to be reworked to use the new EndUserNotificationService in PM-10609 + +@Component({ + selector: "vault-at-risk-password-callout", + standalone: true, + imports: [CommonModule, AnchorLinkDirective, RouterModule, CalloutModule, I18nPipe], + templateUrl: "./at-risk-password-callout.component.html", +}) +export class AtRiskPasswordCalloutComponent { + private taskService = inject(TaskService); + private activeAccount$ = inject(AccountService).activeAccount$.pipe(filterOutNullish()); + + protected pendingTasks$ = this.activeAccount$.pipe( + switchMap((user) => + this.taskService + .pendingTasks$(user.id) + .pipe( + map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)), + ), + ), + ); +} diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-password-page.service.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-password-page.service.ts new file mode 100644 index 00000000000..f8cd4a60650 --- /dev/null +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-password-page.service.ts @@ -0,0 +1,35 @@ +import { inject, Injectable } from "@angular/core"; +import { map, Observable } from "rxjs"; + +import { + BANNERS_DISMISSED_DISK, + StateProvider, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +export const AT_RISK_PASSWORD_AUTOFILL_CALLOUT_DISMISSED_KEY = new UserKeyDefinition( + BANNERS_DISMISSED_DISK, + "atRiskPasswordAutofillBannerDismissed", + { + deserializer: (bannersDismissed) => bannersDismissed, + clearOn: [], // Do not clear dismissed banners + }, +); + +@Injectable() +export class AtRiskPasswordPageService { + private stateProvider = inject(StateProvider); + + isCalloutDismissed(userId: UserId): Observable { + return this.stateProvider + .getUser(userId, AT_RISK_PASSWORD_AUTOFILL_CALLOUT_DISMISSED_KEY) + .state$.pipe(map((dismissed) => !!dismissed)); + } + + async dismissCallout(userId: UserId): Promise { + await this.stateProvider + .getUser(userId, AT_RISK_PASSWORD_AUTOFILL_CALLOUT_DISMISSED_KEY) + .update(() => true); + } +} diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.html b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.html new file mode 100644 index 00000000000..ece00af3df2 --- /dev/null +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.html @@ -0,0 +1,72 @@ + + + + + + + + + {{ "changeAtRiskPasswordsFasterDesc" | i18n }} + + {{ "turnOnAutofill" | i18n }} + + + {{ "dismiss" | i18n }} + + + + + {{ pageDescription$ | async }} + + + + + + + + {{ cipher.name }} + + {{ cipher.subTitle }} + + + + {{ "change" | i18n }} + + + + + + diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts new file mode 100644 index 00000000000..c71c9fa56c0 --- /dev/null +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts @@ -0,0 +1,265 @@ +import { Component, Input } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom, of } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { IconComponent } from "@bitwarden/angular/vault/components/icon.component"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; +import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { ToastService } from "@bitwarden/components"; +import { + PasswordRepromptService, + SecurityTask, + SecurityTaskType, + TaskService, +} from "@bitwarden/vault"; + +import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; + +import { AtRiskPasswordPageService } from "./at-risk-password-page.service"; +import { AtRiskPasswordsComponent } from "./at-risk-passwords.component"; + +@Component({ + standalone: true, + selector: "popup-header", + template: ``, +}) +class MockPopupHeaderComponent { + @Input() pageTitle: string | undefined; + @Input() backAction: (() => void) | undefined; +} + +@Component({ + standalone: true, + selector: "popup-page", + template: ``, +}) +class MockPopupPageComponent { + @Input() loading: boolean | undefined; +} + +@Component({ + standalone: true, + selector: "app-vault-icon", + template: ``, +}) +class MockAppIcon { + @Input() cipher: CipherView | undefined; +} + +describe("AtRiskPasswordsComponent", () => { + let component: AtRiskPasswordsComponent; + let fixture: ComponentFixture; + + let mockTasks$: BehaviorSubject; + let mockCiphers$: BehaviorSubject; + let mockOrgs$: BehaviorSubject; + let mockInlineMenuVisibility$: BehaviorSubject; + let calloutDismissed$: BehaviorSubject; + const setInlineMenuVisibility = jest.fn(); + const mockToastService = mock(); + const mockAtRiskPasswordPageService = mock(); + + beforeEach(async () => { + mockTasks$ = new BehaviorSubject([ + { + id: "task", + organizationId: "org", + cipherId: "cipher", + type: SecurityTaskType.UpdateAtRiskCredential, + } as SecurityTask, + ]); + mockCiphers$ = new BehaviorSubject([ + { + id: "cipher", + organizationId: "org", + name: "Item 1", + } as CipherView, + { + id: "cipher2", + organizationId: "org", + name: "Item 2", + } as CipherView, + ]); + mockOrgs$ = new BehaviorSubject([ + { + id: "org", + name: "Org 1", + } as Organization, + ]); + + mockInlineMenuVisibility$ = new BehaviorSubject( + AutofillOverlayVisibility.Off, + ); + + calloutDismissed$ = new BehaviorSubject(false); + setInlineMenuVisibility.mockClear(); + mockToastService.showToast.mockClear(); + mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$); + + await TestBed.configureTestingModule({ + imports: [AtRiskPasswordsComponent], + providers: [ + { + provide: TaskService, + useValue: { + pendingTasks$: () => mockTasks$, + }, + }, + { + provide: OrganizationService, + useValue: { + organizations$: () => mockOrgs$, + }, + }, + { + provide: CipherService, + useValue: { + cipherViews$: () => mockCiphers$, + }, + }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: AccountService, useValue: { activeAccount$: of({ id: "user" }) } }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: PasswordRepromptService, useValue: mock() }, + { + provide: AutofillSettingsServiceAbstraction, + useValue: { + inlineMenuVisibility$: mockInlineMenuVisibility$, + setInlineMenuVisibility: setInlineMenuVisibility, + }, + }, + { provide: ToastService, useValue: mockToastService }, + ], + }) + .overrideModule(JslibModule, { + remove: { + imports: [IconComponent], + exports: [IconComponent], + }, + add: { + imports: [MockAppIcon], + exports: [MockAppIcon], + }, + }) + .overrideComponent(AtRiskPasswordsComponent, { + remove: { + imports: [PopupHeaderComponent, PopupPageComponent], + providers: [AtRiskPasswordPageService], + }, + add: { + imports: [MockPopupHeaderComponent, MockPopupPageComponent], + providers: [ + { provide: AtRiskPasswordPageService, useValue: mockAtRiskPasswordPageService }, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(AtRiskPasswordsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("pending atRiskItems$", () => { + it("should list pending at risk item tasks", async () => { + const items = await firstValueFrom(component["atRiskItems$"]); + expect(items).toHaveLength(1); + expect(items[0].name).toBe("Item 1"); + }); + }); + + describe("pageDescription$", () => { + it("should use single org description when tasks belong to one org", async () => { + const description = await firstValueFrom(component["pageDescription$"]); + expect(description).toBe("atRiskPasswordsDescSingleOrg"); + }); + + it("should use multiple org description when tasks belong to multiple orgs", async () => { + mockTasks$.next([ + { + id: "task", + organizationId: "org", + cipherId: "cipher", + type: SecurityTaskType.UpdateAtRiskCredential, + } as SecurityTask, + { + id: "task2", + organizationId: "org2", + cipherId: "cipher2", + type: SecurityTaskType.UpdateAtRiskCredential, + } as SecurityTask, + ]); + const description = await firstValueFrom(component["pageDescription$"]); + expect(description).toBe("atRiskPasswordsDescMultiOrg"); + }); + }); + + describe("autofill callout", () => { + it("should show the callout if inline autofill is disabled", async () => { + mockInlineMenuVisibility$.next(AutofillOverlayVisibility.Off); + calloutDismissed$.next(false); + fixture.detectChanges(); + const callout = fixture.debugElement.query(By.css('[data-testid="autofill-callout"]')); + + expect(callout).toBeTruthy(); + }); + + it("should hide the callout if inline autofill is enabled", async () => { + mockInlineMenuVisibility$.next(AutofillOverlayVisibility.OnButtonClick); + calloutDismissed$.next(false); + fixture.detectChanges(); + const callout = fixture.debugElement.query(By.css('[data-testid="autofill-callout"]')); + + expect(callout).toBeFalsy(); + }); + + it("should hide the callout if the user has previously dismissed it", async () => { + mockInlineMenuVisibility$.next(AutofillOverlayVisibility.Off); + calloutDismissed$.next(true); + fixture.detectChanges(); + const callout = fixture.debugElement.query(By.css('[data-testid="autofill-callout"]')); + + expect(callout).toBeFalsy(); + }); + + it("should call dismissCallout when the dismiss callout button is clicked", async () => { + mockInlineMenuVisibility$.next(AutofillOverlayVisibility.Off); + fixture.detectChanges(); + const dismissButton = fixture.debugElement.query( + By.css('[data-testid="dismiss-callout-button"]'), + ); + dismissButton.nativeElement.click(); + expect(mockAtRiskPasswordPageService.dismissCallout).toHaveBeenCalled(); + }); + + describe("turn on autofill button", () => { + it("should call the service to turn on inline autofill and show a toast", () => { + const button = fixture.debugElement.query( + By.css('[data-testid="turn-on-autofill-button"]'), + ); + button.nativeElement.click(); + + expect(setInlineMenuVisibility).toHaveBeenCalledWith( + AutofillOverlayVisibility.OnButtonClick, + ); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts new file mode 100644 index 00000000000..f075335102f --- /dev/null +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts @@ -0,0 +1,162 @@ +import { CommonModule } from "@angular/common"; +import { Component, inject } from "@angular/core"; +import { Router } from "@angular/router"; +import { combineLatest, firstValueFrom, map, of, shareReplay, startWith, switchMap } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + getOrganizationById, + OrganizationService, +} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; +import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + BadgeComponent, + ButtonModule, + CalloutModule, + ItemModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { + filterOutNullish, + PasswordRepromptService, + SecurityTaskType, + TaskService, +} from "@bitwarden/vault"; + +import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; +import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; + +import { AtRiskPasswordPageService } from "./at-risk-password-page.service"; + +@Component({ + selector: "vault-at-risk-passwords", + standalone: true, + imports: [ + PopupPageComponent, + PopupHeaderComponent, + PopOutComponent, + ItemModule, + CommonModule, + JslibModule, + BadgeComponent, + TypographyModule, + CalloutModule, + ButtonModule, + ], + providers: [AtRiskPasswordPageService], + templateUrl: "./at-risk-passwords.component.html", +}) +export class AtRiskPasswordsComponent { + private taskService = inject(TaskService); + private organizationService = inject(OrganizationService); + private cipherService = inject(CipherService); + private i18nService = inject(I18nService); + private accountService = inject(AccountService); + private platformUtilsService = inject(PlatformUtilsService); + private passwordRepromptService = inject(PasswordRepromptService); + private router = inject(Router); + private autofillSettingsService = inject(AutofillSettingsServiceAbstraction); + private toastService = inject(ToastService); + private atRiskPasswordPageService = inject(AtRiskPasswordPageService); + + private activeUserData$ = this.accountService.activeAccount$.pipe( + filterOutNullish(), + switchMap((user) => + combineLatest([ + this.taskService.pendingTasks$(user.id), + this.cipherService.cipherViews$(user.id).pipe( + filterOutNullish(), + map((ciphers) => Object.fromEntries(ciphers.map((c) => [c.id, c]))), + ), + of(user), + ]), + ), + map(([tasks, ciphers, user]) => ({ + tasks, + ciphers, + userId: user.id, + })), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + protected loading$ = this.activeUserData$.pipe( + map(() => false), + startWith(true), + ); + + protected calloutDismissed$ = this.activeUserData$.pipe( + switchMap(({ userId }) => this.atRiskPasswordPageService.isCalloutDismissed(userId)), + ); + + protected inlineAutofillSettingEnabled$ = this.autofillSettingsService.inlineMenuVisibility$.pipe( + map((setting) => setting !== AutofillOverlayVisibility.Off), + ); + + protected atRiskItems$ = this.activeUserData$.pipe( + map(({ tasks, ciphers }) => + tasks + .filter( + (t) => + t.type === SecurityTaskType.UpdateAtRiskCredential && + t.cipherId != null && + ciphers[t.cipherId] != null, + ) + .map((t) => ciphers[t.cipherId!]), + ), + ); + + protected pageDescription$ = this.activeUserData$.pipe( + switchMap(({ tasks, userId }) => { + const orgIds = new Set(tasks.map((t) => t.organizationId)); + if (orgIds.size === 1) { + const [orgId] = orgIds; + return this.organizationService.organizations$(userId).pipe( + getOrganizationById(orgId), + map((org) => this.i18nService.t("atRiskPasswordsDescSingleOrg", org?.name, tasks.length)), + ); + } + + return of(this.i18nService.t("atRiskPasswordsDescMultiOrg", tasks.length)); + }), + ); + + async viewCipher(cipher: CipherView) { + const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); + if (!repromptPassed) { + return; + } + await this.router.navigate(["/view-cipher"], { + queryParams: { cipherId: cipher.id, type: cipher.type }, + }); + } + + async launchChangePassword(cipher: CipherView) { + if (cipher.login?.uri) { + this.platformUtilsService.launchUri(cipher.login.uri); + } + } + + async activateInlineAutofillMenuVisibility() { + await this.autofillSettingsService.setInlineMenuVisibility( + AutofillOverlayVisibility.OnButtonClick, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("turnedOnAutofill"), + title: "", + }); + } + + async dismissCallout() { + const { userId } = await firstValueFrom(this.activeUserData$); + await this.atRiskPasswordPageService.dismissCallout(userId); + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index 6e017042711..8cb538a429a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -32,6 +32,9 @@ slot="above-scroll-area" *ngIf="vaultState !== VaultStateEnum.Empty && !(loading$ | async)" > + diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 635ae82fc37..be5c33aab70 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -17,10 +17,17 @@ import { import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { ButtonModule, DialogService, Icons, NoItemsModule } from "@bitwarden/components"; +import { + BannerComponent, + ButtonModule, + DialogService, + Icons, + NoItemsModule, +} from "@bitwarden/components"; import { DecryptionFailureDialogComponent, VaultIcons } from "@bitwarden/vault"; import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component"; @@ -30,6 +37,7 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page import { VaultPopupItemsService } from "../../services/vault-popup-items.service"; import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service"; import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service"; +import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component"; import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component"; import { @@ -67,6 +75,8 @@ enum VaultState { ScrollingModule, VaultHeaderV2Component, DecryptionFailureDialogComponent, + BannerComponent, + AtRiskPasswordCalloutComponent, ], }) export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { @@ -168,4 +178,6 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { ngOnDestroy(): void { this.vaultScrollPositionService.stop(); } + + protected readonly FeatureFlag = FeatureFlag; } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 719e3a084f1..d6db49c109d 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -296,7 +296,12 @@ import { DefaultUserAsymmetricKeysRegenerationApiService, } from "@bitwarden/key-management"; import { SafeInjectionToken } from "@bitwarden/ui-common"; -import { NewDeviceVerificationNoticeService, PasswordRepromptService } from "@bitwarden/vault"; +import { + DefaultTaskService, + NewDeviceVerificationNoticeService, + PasswordRepromptService, + TaskService, +} from "@bitwarden/vault"; import { VaultExportService, VaultExportServiceAbstraction, @@ -1463,6 +1468,11 @@ const safeProviders: SafeProvider[] = [ useClass: PasswordLoginStrategyData, deps: [], }), + safeProvider({ + provide: TaskService, + useClass: DefaultTaskService, + deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService], + }), ]; @NgModule({ diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index ad9a2a75d01..d0823580506 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -5,6 +5,8 @@ export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directi export { OrgIconDirective } from "./components/org-icon.directive"; export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive"; +export * from "./utils/observable-utilities"; + export * from "./cipher-view"; export * from "./cipher-form"; export { diff --git a/libs/vault/src/tasks/services/default-task.service.spec.ts b/libs/vault/src/tasks/services/default-task.service.spec.ts index 26b1a79ca2e..c6b74f82909 100644 --- a/libs/vault/src/tasks/services/default-task.service.spec.ts +++ b/libs/vault/src/tasks/services/default-task.service.spec.ts @@ -4,6 +4,7 @@ import { BehaviorSubject, firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid"; import { DefaultTaskService, SecurityTaskStatus } from "@bitwarden/vault"; @@ -18,18 +19,26 @@ describe("Default task service", () => { const mockApiSend = jest.fn(); const mockGetAllOrgs$ = jest.fn(); + const mockGetFeatureFlag$ = jest.fn(); let testBed: TestBed; beforeEach(async () => { mockApiSend.mockClear(); mockGetAllOrgs$.mockClear(); + mockGetFeatureFlag$.mockClear(); fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId)); testBed = TestBed.configureTestingModule({ imports: [], providers: [ DefaultTaskService, + { + provide: ConfigService, + useValue: { + getFeatureFlag$: mockGetFeatureFlag$, + }, + }, { provide: StateProvider, useValue: fakeStateProvider, @@ -52,6 +61,7 @@ describe("Default task service", () => { describe("tasksEnabled$", () => { it("should emit true if any organization uses risk insights", async () => { + mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true)); mockGetAllOrgs$.mockReturnValue( new BehaviorSubject([ { @@ -71,6 +81,7 @@ describe("Default task service", () => { }); it("should emit false if no organization uses risk insights", async () => { + mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true)); mockGetAllOrgs$.mockReturnValue( new BehaviorSubject([ { @@ -88,6 +99,23 @@ describe("Default task service", () => { expect(result).toBe(false); }); + + it("should emit false if the feature flag is off", async () => { + mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(false)); + mockGetAllOrgs$.mockReturnValue( + new BehaviorSubject([ + { + useRiskInsights: true, + }, + ] as Organization[]), + ); + + const { tasksEnabled$ } = testBed.inject(DefaultTaskService); + + const result = await firstValueFrom(tasksEnabled$("user-id" as UserId)); + + expect(result).toBe(false); + }); }); describe("tasks$", () => { @@ -100,7 +128,7 @@ describe("Default task service", () => { ] as SecurityTaskResponse[], }); - fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, null); + fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, null as any); const { tasks$ } = testBed.inject(DefaultTaskService); @@ -183,7 +211,11 @@ describe("Default task service", () => { ] as SecurityTaskResponse[], }); - const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, null); + const mock = fakeStateProvider.singleUser.mockFor( + "user-id" as UserId, + SECURITY_TASKS, + null as any, + ); const service = testBed.inject(DefaultTaskService); diff --git a/libs/vault/src/tasks/services/default-task.service.ts b/libs/vault/src/tasks/services/default-task.service.ts index 998b2077283..b6d0ff77e9d 100644 --- a/libs/vault/src/tasks/services/default-task.service.ts +++ b/libs/vault/src/tasks/services/default-task.service.ts @@ -1,9 +1,11 @@ import { Injectable } from "@angular/core"; -import { map, switchMap } from "rxjs"; +import { combineLatest, map, switchMap } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid"; import { SecurityTask, SecurityTaskStatus, TaskService } from "@bitwarden/vault"; @@ -19,12 +21,16 @@ export class DefaultTaskService implements TaskService { private stateProvider: StateProvider, private apiService: ApiService, private organizationService: OrganizationService, + private configService: ConfigService, ) {} tasksEnabled$ = perUserCache$((userId) => { - return this.organizationService - .organizations$(userId) - .pipe(map((orgs) => orgs.some((o) => o.useRiskInsights))); + return combineLatest([ + this.organizationService + .organizations$(userId) + .pipe(map((orgs) => orgs.some((o) => o.useRiskInsights))), + this.configService.getFeatureFlag$(FeatureFlag.SecurityTasks), + ]).pipe(map(([atLeastOneOrgEnabled, flagEnabled]) => atLeastOneOrgEnabled && flagEnabled)); }); tasks$ = perUserCache$((userId) => {
{{ "changeAtRiskPasswordsFasterDesc" | i18n }}
{{ pageDescription$ | async }}