diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index ea9e62916a..ce73bd99d3 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -2486,6 +2486,36 @@
"changeAtRiskPasswordsFasterDesc": {
"message": "Update your settings so you can quickly autofill your passwords and generate new ones"
},
+ "reviewAtRiskLogins": {
+ "message": "Review at-risk logins"
+ },
+ "reviewAtRiskPasswords": {
+ "message": "Review at-risk passwords"
+ },
+ "reviewAtRiskLoginsSlideDesc": {
+ "message": "Your organization passwords are at-risk because they are weak, reused, and/or exposed.",
+ "description": "Description of the review at-risk login slide on the at-risk password page carousel"
+ },
+ "reviewAtRiskLoginSlideImgAlt": {
+ "message": "Illustration of a list of logins that are at-risk"
+ },
+ "generatePasswordSlideDesc": {
+ "message": "Quickly generate a strong, unique password with the Bitwarden autofill menu on the at-risk site.",
+ "description": "Description of the generate password slide on the at-risk password page carousel"
+ },
+ "generatePasswordSlideImgAlt": {
+ "message": "Illustration of the Bitwarden autofill menu displaying a generated password"
+ },
+ "updateInBitwarden": {
+ "message": "Update in Bitwarden"
+ },
+ "updateInBitwardenSlideDesc": {
+ "message": "Bitwarden will then prompt you to update the password in the password manager.",
+ "description": "Description of the update in Bitwarden slide on the at-risk password page carousel"
+ },
+ "updateInBitwardenSlideImgAlt": {
+ "message": "Illustration of a Bitwarden’s notification prompting the user to update the login"
+ },
"turnOnAutofill": {
"message": "Turn on autofill"
},
diff --git a/apps/browser/src/images/at-risk-password-carousel/generate_password.dark.png b/apps/browser/src/images/at-risk-password-carousel/generate_password.dark.png
new file mode 100644
index 0000000000..8d0fbbdec5
Binary files /dev/null and b/apps/browser/src/images/at-risk-password-carousel/generate_password.dark.png differ
diff --git a/apps/browser/src/images/at-risk-password-carousel/generate_password.light.png b/apps/browser/src/images/at-risk-password-carousel/generate_password.light.png
new file mode 100644
index 0000000000..4e46ce2adc
Binary files /dev/null and b/apps/browser/src/images/at-risk-password-carousel/generate_password.light.png differ
diff --git a/apps/browser/src/images/at-risk-password-carousel/review_at-risk_logins.dark.png b/apps/browser/src/images/at-risk-password-carousel/review_at-risk_logins.dark.png
new file mode 100644
index 0000000000..47e1d40bd4
Binary files /dev/null and b/apps/browser/src/images/at-risk-password-carousel/review_at-risk_logins.dark.png differ
diff --git a/apps/browser/src/images/at-risk-password-carousel/review_at-risk_logins.light.png b/apps/browser/src/images/at-risk-password-carousel/review_at-risk_logins.light.png
new file mode 100644
index 0000000000..a248f04c97
Binary files /dev/null and b/apps/browser/src/images/at-risk-password-carousel/review_at-risk_logins.light.png differ
diff --git a/apps/browser/src/images/at-risk-password-carousel/update_login.dark.png b/apps/browser/src/images/at-risk-password-carousel/update_login.dark.png
new file mode 100644
index 0000000000..a04d3e369b
Binary files /dev/null and b/apps/browser/src/images/at-risk-password-carousel/update_login.dark.png differ
diff --git a/apps/browser/src/images/at-risk-password-carousel/update_login.light.png b/apps/browser/src/images/at-risk-password-carousel/update_login.light.png
new file mode 100644
index 0000000000..3bc176f8ac
Binary files /dev/null and b/apps/browser/src/images/at-risk-password-carousel/update_login.light.png differ
diff --git a/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.html b/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.html
new file mode 100644
index 0000000000..aee456a8f2
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.html
@@ -0,0 +1,54 @@
+
+
+
+
+
+
{{ "reviewAtRiskLogins" | i18n }}
+
+ {{ "reviewAtRiskLoginsSlideDesc" | i18n }}
+
+
+
+
+
{{ "generatePassword" | i18n }}
+
+ {{ "generatePasswordSlideDesc" | i18n }}
+
+
+
+
+
{{ "updateInBitwarden" | i18n }}
+
+ {{ "updateInBitwardenSlideDesc" | i18n }}
+
+
+
+
+
+
+
+
diff --git a/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.ts b/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.ts
new file mode 100644
index 0000000000..9af9f0aeda
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.ts
@@ -0,0 +1,46 @@
+import { DialogRef } from "@angular/cdk/dialog";
+import { Component, inject, signal } from "@angular/core";
+
+import { ButtonModule, DialogModule, DialogService, TypographyModule } from "@bitwarden/components";
+import { I18nPipe } from "@bitwarden/ui-common";
+import { DarkImageSourceDirective, VaultCarouselModule } from "@bitwarden/vault";
+
+export enum AtRiskCarouselDialogResult {
+ Dismissed = "dismissed",
+}
+
+@Component({
+ selector: "vault-at-risk-carousel-dialog",
+ templateUrl: "./at-risk-carousel-dialog.component.html",
+ imports: [
+ DialogModule,
+ VaultCarouselModule,
+ TypographyModule,
+ ButtonModule,
+ DarkImageSourceDirective,
+ I18nPipe,
+ ],
+ standalone: true,
+})
+export class AtRiskCarouselDialogComponent {
+ private dialogRef = inject(DialogRef);
+
+ protected dismissBtnEnabled = signal(false);
+
+ protected async dismiss() {
+ this.dialogRef.close(AtRiskCarouselDialogResult.Dismissed);
+ }
+
+ protected onSlideChange(slideIndex: number) {
+ // Only enable the dismiss button on the last slide
+ if (slideIndex === 2) {
+ this.dismissBtnEnabled.set(true);
+ }
+ }
+
+ static open(dialogService: DialogService) {
+ return dialogService.open(AtRiskCarouselDialogComponent, {
+ disableClose: true,
+ });
+ }
+}
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
index f8cd4a6065..4f3c235dc3 100644
--- 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
@@ -2,18 +2,27 @@ import { inject, Injectable } from "@angular/core";
import { map, Observable } from "rxjs";
import {
- BANNERS_DISMISSED_DISK,
+ AT_RISK_PASSWORDS_PAGE_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",
+const AUTOFILL_CALLOUT_DISMISSED_KEY = new UserKeyDefinition(
+ AT_RISK_PASSWORDS_PAGE_DISK,
+ "autofillCalloutDismissed",
{
deserializer: (bannersDismissed) => bannersDismissed,
- clearOn: [], // Do not clear dismissed banners
+ clearOn: [], // Do not clear dismissed callout
+ },
+);
+
+const GETTING_STARTED_CAROUSEL_DISMISSED_KEY = new UserKeyDefinition(
+ AT_RISK_PASSWORDS_PAGE_DISK,
+ "gettingStartedCarouselDismissed",
+ {
+ deserializer: (bannersDismissed) => bannersDismissed,
+ clearOn: [], // Do not clear dismissed carousel
},
);
@@ -23,13 +32,23 @@ export class AtRiskPasswordPageService {
isCalloutDismissed(userId: UserId): Observable {
return this.stateProvider
- .getUser(userId, AT_RISK_PASSWORD_AUTOFILL_CALLOUT_DISMISSED_KEY)
+ .getUser(userId, AUTOFILL_CALLOUT_DISMISSED_KEY)
.state$.pipe(map((dismissed) => !!dismissed));
}
async dismissCallout(userId: UserId): Promise {
+ await this.stateProvider.getUser(userId, AUTOFILL_CALLOUT_DISMISSED_KEY).update(() => true);
+ }
+
+ isGettingStartedDismissed(userId: UserId): Observable {
+ return this.stateProvider
+ .getUser(userId, GETTING_STARTED_CAROUSEL_DISMISSED_KEY)
+ .state$.pipe(map((dismissed) => !!dismissed));
+ }
+
+ async dismissGettingStarted(userId: UserId): Promise {
await this.stateProvider
- .getUser(userId, AT_RISK_PASSWORD_AUTOFILL_CALLOUT_DISMISSED_KEY)
+ .getUser(userId, GETTING_STARTED_CAROUSEL_DISMISSED_KEY)
.update(() => true);
}
}
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
index 39b9650538..c719618b33 100644
--- 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
@@ -16,7 +16,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
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 { DialogService, ToastService } from "@bitwarden/components";
import {
ChangeLoginPasswordService,
DefaultChangeLoginPasswordService,
@@ -28,6 +28,7 @@ import {
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
+import { AtRiskCarouselDialogResult } from "../at-risk-carousel-dialog/at-risk-carousel-dialog.component";
import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
import { AtRiskPasswordsComponent } from "./at-risk-passwords.component";
@@ -73,6 +74,7 @@ describe("AtRiskPasswordsComponent", () => {
const mockToastService = mock();
const mockAtRiskPasswordPageService = mock();
const mockChangeLoginPasswordService = mock();
+ const mockDialogService = mock();
beforeEach(async () => {
mockTasks$ = new BehaviorSubject([
@@ -109,6 +111,7 @@ describe("AtRiskPasswordsComponent", () => {
calloutDismissed$ = new BehaviorSubject(false);
setInlineMenuVisibility.mockClear();
mockToastService.showToast.mockClear();
+ mockDialogService.open.mockClear();
mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$);
await TestBed.configureTestingModule({
@@ -162,6 +165,7 @@ describe("AtRiskPasswordsComponent", () => {
providers: [
AtRiskPasswordPageService,
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
+ DialogService,
],
},
add: {
@@ -169,6 +173,7 @@ describe("AtRiskPasswordsComponent", () => {
providers: [
{ provide: AtRiskPasswordPageService, useValue: mockAtRiskPasswordPageService },
{ provide: ChangeLoginPasswordService, useValue: mockChangeLoginPasswordService },
+ { provide: DialogService, useValue: mockDialogService },
],
},
})
@@ -269,4 +274,31 @@ describe("AtRiskPasswordsComponent", () => {
});
});
});
+
+ describe("getting started carousel", () => {
+ it("should open the carousel automatically if the user has not dismissed it", async () => {
+ mockAtRiskPasswordPageService.isGettingStartedDismissed.mockReturnValue(of(false));
+ mockDialogService.open.mockReturnValue({ closed: of(undefined) } as any);
+ await component.ngOnInit();
+ expect(mockDialogService.open).toHaveBeenCalled();
+ });
+
+ it("should not open the carousel automatically if the user has already dismissed it", async () => {
+ mockDialogService.open.mockClear(); // Need to clear the mock since the component is already initialized once
+ mockAtRiskPasswordPageService.isGettingStartedDismissed.mockReturnValue(of(true));
+ mockDialogService.open.mockReturnValue({ closed: of(undefined) } as any);
+ await component.ngOnInit();
+ expect(mockDialogService.open).not.toHaveBeenCalled();
+ });
+
+ it("should mark the carousel as dismissed when the user dismisses it", async () => {
+ mockAtRiskPasswordPageService.isGettingStartedDismissed.mockReturnValue(of(false));
+ mockDialogService.open.mockReturnValue({
+ closed: of(AtRiskCarouselDialogResult.Dismissed),
+ } as any);
+ await component.ngOnInit();
+ expect(mockDialogService.open).toHaveBeenCalled();
+ expect(mockAtRiskPasswordPageService.dismissGettingStarted).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
index 4753bc77ec..471bdfeed1 100644
--- 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
@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
-import { Component, inject, signal } from "@angular/core";
+import { Component, inject, OnInit, signal } from "@angular/core";
import { Router } from "@angular/router";
import { combineLatest, firstValueFrom, map, of, shareReplay, startWith, switchMap } from "rxjs";
@@ -19,6 +19,8 @@ import {
BadgeModule,
ButtonModule,
CalloutModule,
+ DialogModule,
+ DialogService,
ItemModule,
ToastService,
TypographyModule,
@@ -30,11 +32,16 @@ import {
PasswordRepromptService,
SecurityTaskType,
TaskService,
+ VaultCarouselModule,
} 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 {
+ AtRiskCarouselDialogComponent,
+ AtRiskCarouselDialogResult,
+} from "../at-risk-carousel-dialog/at-risk-carousel-dialog.component";
import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
@@ -50,6 +57,8 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
CalloutModule,
ButtonModule,
BadgeModule,
+ DialogModule,
+ VaultCarouselModule,
],
providers: [
AtRiskPasswordPageService,
@@ -59,7 +68,7 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
standalone: true,
templateUrl: "./at-risk-passwords.component.html",
})
-export class AtRiskPasswordsComponent {
+export class AtRiskPasswordsComponent implements OnInit {
private taskService = inject(TaskService);
private organizationService = inject(OrganizationService);
private cipherService = inject(CipherService);
@@ -72,6 +81,7 @@ export class AtRiskPasswordsComponent {
private atRiskPasswordPageService = inject(AtRiskPasswordPageService);
private changeLoginPasswordService = inject(ChangeLoginPasswordService);
private platformUtilsService = inject(PlatformUtilsService);
+ private dialogService = inject(DialogService);
/**
* The cipher that is currently being launched. Used to show a loading spinner on the badge button.
@@ -141,6 +151,21 @@ export class AtRiskPasswordsComponent {
}),
);
+ async ngOnInit() {
+ const { userId } = await firstValueFrom(this.activeUserData$);
+ const gettingStartedDismissed = await firstValueFrom(
+ this.atRiskPasswordPageService.isGettingStartedDismissed(userId),
+ );
+ if (!gettingStartedDismissed) {
+ const ref = AtRiskCarouselDialogComponent.open(this.dialogService);
+
+ const result = await firstValueFrom(ref.closed);
+ if (result === AtRiskCarouselDialogResult.Dismissed) {
+ await this.atRiskPasswordPageService.dismissGettingStarted(userId);
+ }
+ }
+ }
+
async viewCipher(cipher: CipherView) {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
if (!repromptPassed) {
diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts
index c7901bc34e..84d5b81ee7 100644
--- a/libs/common/src/platform/state/state-definitions.ts
+++ b/libs/common/src/platform/state/state-definitions.ts
@@ -199,3 +199,4 @@ export const NEW_DEVICE_VERIFICATION_NOTICE = new StateDefinition(
);
export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
+export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.component.html b/libs/components/src/dialog/simple-dialog/simple-dialog.component.html
index 1f154a8d54..d810838cab 100644
--- a/libs/components/src/dialog/simple-dialog/simple-dialog.component.html
+++ b/libs/components/src/dialog/simple-dialog/simple-dialog.component.html
@@ -3,10 +3,12 @@
@fadeIn
>