mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 23:33:31 +00:00
[PM-14426] At-risk password Getting Started Carousel (#13383)
* [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
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -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>
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ToastService>();
|
||||
const mockAtRiskPasswordPageService = mock<AtRiskPasswordPageService>();
|
||||
const mockChangeLoginPasswordService = mock<ChangeLoginPasswordService>();
|
||||
const mockDialogService = mock<DialogService>();
|
||||
|
||||
beforeEach(async () => {
|
||||
mockTasks$ = new BehaviorSubject<SecurityTask[]>([
|
||||
@@ -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({
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user