Skip to content

Commit

Permalink
[PM-14426] At-risk password Getting Started Carousel (#13383)
Browse files Browse the repository at this point in the history
* [PM-14426] Add hideIcon input to simple dialog component

* [PM-14426] Introduce dark-image-source.directive.ts

* [PM-14426] Tweaks to the Vault Carousel component
- Create a Carousel NgModule so that the carousel component and carousel slide component are exported
- Update barrel files
- Adjust min height calculation logic to wait for ;hidden slides to finish rendering before calculating height

* [PM-14426] Introduce at risk password getting started carousel component and images

* [PM-14426] Refactor at-risk-password-page.service.ts to use the same state definition for banner and carousel dismissal

* [PM-14426] Open the getting started carousel on page load

* [PM-14426] Add tests

* [PM-14426] Use booleanAttribute

* [PM-14426] Fix failing type checking
  • Loading branch information
shane-melton authored Feb 26, 2025
1 parent 9aee5f1 commit b9ebf07
Show file tree
Hide file tree
Showing 22 changed files with 334 additions and 30 deletions.
30 changes: 30 additions & 0 deletions apps/browser/src/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<bit-simple-dialog hideIcon>
<div bitDialogContent>
<vault-carousel label="Placeholder" (slideChange)="onSlideChange($event)">
<vault-carousel-slide [label]="'reviewAtRiskLogins' | i18n">
<img
class="tw-max-w-full tw-max-h-40"
src="../../../../images/at-risk-password-carousel/review_at-risk_logins.light.png"
appDarkImgSrc="../../../../images/at-risk-password-carousel/review_at-risk_logins.dark.png"
[alt]="'reviewAtRiskLoginSlideImgAlt' | i18n"
/>
<h2 bitTypography="h2" class="tw-mt-8">{{ "reviewAtRiskLogins" | i18n }}</h2>
<p bitTypography="body1">
{{ "reviewAtRiskLoginsSlideDesc" | i18n }}
</p>
</vault-carousel-slide>
<vault-carousel-slide [label]="'generatePassword' | i18n">
<img
class="tw-max-w-full tw-max-h-40"
src="../../../../images/at-risk-password-carousel/generate_password.light.png"
appDarkImgSrc="../../../../images/at-risk-password-carousel/generate_password.dark.png"
[alt]="'generatePasswordSlideImgAlt' | i18n"
/>
<h2 bitTypography="h2" class="tw-mt-8">{{ "generatePassword" | i18n }}</h2>
<p bitTypography="body1">
{{ "generatePasswordSlideDesc" | i18n }}
</p>
</vault-carousel-slide>
<vault-carousel-slide [label]="'updateInBitwarden' | i18n">
<img
class="tw-max-w-full tw-max-h-40"
src="../../../../images/at-risk-password-carousel/update_login.light.png"
appDarkImgSrc="../../../../images/at-risk-password-carousel/update_login.dark.png"
[alt]="'updateInBitwardenSlideImgAlt' | i18n"
/>
<h2 bitTypography="h2" class="tw-mt-8">{{ "updateInBitwarden" | i18n }}</h2>
<p bitTypography="body1">
{{ "updateInBitwardenSlideDesc" | i18n }}
</p>
</vault-carousel-slide>
</vault-carousel>
</div>
<div bitDialogFooter class="tw-w-full">
<button
type="button"
bitButton
buttonType="primary"
block
[disabled]="!dismissBtnEnabled()"
(click)="dismiss()"
>
{{ "reviewAtRiskPasswords" | i18n }}
</button>
</div>
</bit-simple-dialog>
Original file line number Diff line number Diff line change
@@ -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<AtRiskCarouselDialogResult>(AtRiskCarouselDialogComponent, {
disableClose: true,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(
BANNERS_DISMISSED_DISK,
"atRiskPasswordAutofillBannerDismissed",
const AUTOFILL_CALLOUT_DISMISSED_KEY = new UserKeyDefinition<boolean>(
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<boolean>(
AT_RISK_PASSWORDS_PAGE_DISK,
"gettingStartedCarouselDismissed",
{
deserializer: (bannersDismissed) => bannersDismissed,
clearOn: [], // Do not clear dismissed carousel
},
);

Expand All @@ -23,13 +32,23 @@ export class AtRiskPasswordPageService {

isCalloutDismissed(userId: UserId): Observable<boolean> {
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<void> {
await this.stateProvider.getUser(userId, AUTOFILL_CALLOUT_DISMISSED_KEY).update(() => true);
}

isGettingStartedDismissed(userId: UserId): Observable<boolean> {
return this.stateProvider
.getUser(userId, GETTING_STARTED_CAROUSEL_DISMISSED_KEY)
.state$.pipe(map((dismissed) => !!dismissed));
}

async dismissGettingStarted(userId: UserId): Promise<void> {
await this.stateProvider
.getUser(userId, AT_RISK_PASSWORD_AUTOFILL_CALLOUT_DISMISSED_KEY)
.getUser(userId, GETTING_STARTED_CAROUSEL_DISMISSED_KEY)
.update(() => true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -73,6 +74,7 @@ describe("AtRiskPasswordsComponent", () => {
const mockToastService = mock<ToastService>();
const mockAtRiskPasswordPageService = mock<AtRiskPasswordPageService>();
const mockChangeLoginPasswordService = mock<ChangeLoginPasswordService>();
const mockDialogService = mock<DialogService>();

beforeEach(async () => {
mockTasks$ = new BehaviorSubject<SecurityTask[]>([
Expand Down Expand Up @@ -109,6 +111,7 @@ describe("AtRiskPasswordsComponent", () => {
calloutDismissed$ = new BehaviorSubject<boolean>(false);
setInlineMenuVisibility.mockClear();
mockToastService.showToast.mockClear();
mockDialogService.open.mockClear();
mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$);

await TestBed.configureTestingModule({
Expand Down Expand Up @@ -162,13 +165,15 @@ describe("AtRiskPasswordsComponent", () => {
providers: [
AtRiskPasswordPageService,
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
DialogService,
],
},
add: {
imports: [MockPopupHeaderComponent, MockPopupPageComponent],
providers: [
{ provide: AtRiskPasswordPageService, useValue: mockAtRiskPasswordPageService },
{ provide: ChangeLoginPasswordService, useValue: mockChangeLoginPasswordService },
{ provide: DialogService, useValue: mockDialogService },
],
},
})
Expand Down Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -19,6 +19,8 @@ import {
BadgeModule,
ButtonModule,
CalloutModule,
DialogModule,
DialogService,
ItemModule,
ToastService,
TypographyModule,
Expand All @@ -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";

Expand All @@ -50,6 +57,8 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
CalloutModule,
ButtonModule,
BadgeModule,
DialogModule,
VaultCarouselModule,
],
providers: [
AtRiskPasswordPageService,
Expand All @@ -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);
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions libs/common/src/platform/state/state-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
@fadeIn
>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2 tw-px-4 tw-pt-4 tw-text-center">
@if (hasIcon) {
<ng-content select="[bitDialogIcon]"></ng-content>
} @else {
<i class="bwi bwi-exclamation-triangle tw-text-3xl tw-text-warning" aria-hidden="true"></i>
@if (!hideIcon()) {
@if (hasIcon) {
<ng-content select="[bitDialogIcon]"></ng-content>
} @else {
<i class="bwi bwi-exclamation-triangle tw-text-3xl tw-text-warning" aria-hidden="true"></i>
}
}
<h1
bitDialogTitleContainer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, ContentChild, Directive } from "@angular/core";
import { booleanAttribute, Component, ContentChild, Directive, input } from "@angular/core";

import { TypographyDirective } from "../../typography/typography.directive";
import { fadeIn } from "../animations";
Expand All @@ -20,6 +20,11 @@ export class IconDirective {}
export class SimpleDialogComponent {
@ContentChild(IconDirective) icon!: IconDirective;

/**
* Optional flag to hide the dialog's center icon. Defaults to false.
*/
hideIcon = input(false, { transform: booleanAttribute });

get hasIcon() {
return this.icon != null;
}
Expand Down
Loading

0 comments on commit b9ebf07

Please sign in to comment.