mirror of
https://github.com/bitwarden/browser.git
synced 2025-02-28 03:21:40 +01: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:
parent
9aee5f16c4
commit
b9ebf0704a
@ -2486,6 +2486,36 @@
|
|||||||
"changeAtRiskPasswordsFasterDesc": {
|
"changeAtRiskPasswordsFasterDesc": {
|
||||||
"message": "Update your settings so you can quickly autofill your passwords and generate new ones"
|
"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": {
|
"turnOnAutofill": {
|
||||||
"message": "Turn on autofill"
|
"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 { map, Observable } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BANNERS_DISMISSED_DISK,
|
AT_RISK_PASSWORDS_PAGE_DISK,
|
||||||
StateProvider,
|
StateProvider,
|
||||||
UserKeyDefinition,
|
UserKeyDefinition,
|
||||||
} from "@bitwarden/common/platform/state";
|
} from "@bitwarden/common/platform/state";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
export const AT_RISK_PASSWORD_AUTOFILL_CALLOUT_DISMISSED_KEY = new UserKeyDefinition<boolean>(
|
const AUTOFILL_CALLOUT_DISMISSED_KEY = new UserKeyDefinition<boolean>(
|
||||||
BANNERS_DISMISSED_DISK,
|
AT_RISK_PASSWORDS_PAGE_DISK,
|
||||||
"atRiskPasswordAutofillBannerDismissed",
|
"autofillCalloutDismissed",
|
||||||
{
|
{
|
||||||
deserializer: (bannersDismissed) => bannersDismissed,
|
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> {
|
isCalloutDismissed(userId: UserId): Observable<boolean> {
|
||||||
return this.stateProvider
|
return this.stateProvider
|
||||||
.getUser(userId, AT_RISK_PASSWORD_AUTOFILL_CALLOUT_DISMISSED_KEY)
|
.getUser(userId, AUTOFILL_CALLOUT_DISMISSED_KEY)
|
||||||
.state$.pipe(map((dismissed) => !!dismissed));
|
.state$.pipe(map((dismissed) => !!dismissed));
|
||||||
}
|
}
|
||||||
|
|
||||||
async dismissCallout(userId: UserId): Promise<void> {
|
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
|
await this.stateProvider
|
||||||
.getUser(userId, AT_RISK_PASSWORD_AUTOFILL_CALLOUT_DISMISSED_KEY)
|
.getUser(userId, GETTING_STARTED_CAROUSEL_DISMISSED_KEY)
|
||||||
.update(() => true);
|
.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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { ToastService } from "@bitwarden/components";
|
import { DialogService, ToastService } from "@bitwarden/components";
|
||||||
import {
|
import {
|
||||||
ChangeLoginPasswordService,
|
ChangeLoginPasswordService,
|
||||||
DefaultChangeLoginPasswordService,
|
DefaultChangeLoginPasswordService,
|
||||||
@ -28,6 +28,7 @@ import {
|
|||||||
|
|
||||||
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||||
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.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 { AtRiskPasswordPageService } from "./at-risk-password-page.service";
|
||||||
import { AtRiskPasswordsComponent } from "./at-risk-passwords.component";
|
import { AtRiskPasswordsComponent } from "./at-risk-passwords.component";
|
||||||
@ -73,6 +74,7 @@ describe("AtRiskPasswordsComponent", () => {
|
|||||||
const mockToastService = mock<ToastService>();
|
const mockToastService = mock<ToastService>();
|
||||||
const mockAtRiskPasswordPageService = mock<AtRiskPasswordPageService>();
|
const mockAtRiskPasswordPageService = mock<AtRiskPasswordPageService>();
|
||||||
const mockChangeLoginPasswordService = mock<ChangeLoginPasswordService>();
|
const mockChangeLoginPasswordService = mock<ChangeLoginPasswordService>();
|
||||||
|
const mockDialogService = mock<DialogService>();
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mockTasks$ = new BehaviorSubject<SecurityTask[]>([
|
mockTasks$ = new BehaviorSubject<SecurityTask[]>([
|
||||||
@ -109,6 +111,7 @@ describe("AtRiskPasswordsComponent", () => {
|
|||||||
calloutDismissed$ = new BehaviorSubject<boolean>(false);
|
calloutDismissed$ = new BehaviorSubject<boolean>(false);
|
||||||
setInlineMenuVisibility.mockClear();
|
setInlineMenuVisibility.mockClear();
|
||||||
mockToastService.showToast.mockClear();
|
mockToastService.showToast.mockClear();
|
||||||
|
mockDialogService.open.mockClear();
|
||||||
mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$);
|
mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$);
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@ -162,6 +165,7 @@ describe("AtRiskPasswordsComponent", () => {
|
|||||||
providers: [
|
providers: [
|
||||||
AtRiskPasswordPageService,
|
AtRiskPasswordPageService,
|
||||||
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
|
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
|
||||||
|
DialogService,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
add: {
|
add: {
|
||||||
@ -169,6 +173,7 @@ describe("AtRiskPasswordsComponent", () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: AtRiskPasswordPageService, useValue: mockAtRiskPasswordPageService },
|
{ provide: AtRiskPasswordPageService, useValue: mockAtRiskPasswordPageService },
|
||||||
{ provide: ChangeLoginPasswordService, useValue: mockChangeLoginPasswordService },
|
{ 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 { 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 { Router } from "@angular/router";
|
||||||
import { combineLatest, firstValueFrom, map, of, shareReplay, startWith, switchMap } from "rxjs";
|
import { combineLatest, firstValueFrom, map, of, shareReplay, startWith, switchMap } from "rxjs";
|
||||||
|
|
||||||
@ -19,6 +19,8 @@ import {
|
|||||||
BadgeModule,
|
BadgeModule,
|
||||||
ButtonModule,
|
ButtonModule,
|
||||||
CalloutModule,
|
CalloutModule,
|
||||||
|
DialogModule,
|
||||||
|
DialogService,
|
||||||
ItemModule,
|
ItemModule,
|
||||||
ToastService,
|
ToastService,
|
||||||
TypographyModule,
|
TypographyModule,
|
||||||
@ -30,11 +32,16 @@ import {
|
|||||||
PasswordRepromptService,
|
PasswordRepromptService,
|
||||||
SecurityTaskType,
|
SecurityTaskType,
|
||||||
TaskService,
|
TaskService,
|
||||||
|
VaultCarouselModule,
|
||||||
} from "@bitwarden/vault";
|
} from "@bitwarden/vault";
|
||||||
|
|
||||||
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
|
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
|
||||||
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||||
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.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";
|
import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
|
||||||
|
|
||||||
@ -50,6 +57,8 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
|
|||||||
CalloutModule,
|
CalloutModule,
|
||||||
ButtonModule,
|
ButtonModule,
|
||||||
BadgeModule,
|
BadgeModule,
|
||||||
|
DialogModule,
|
||||||
|
VaultCarouselModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AtRiskPasswordPageService,
|
AtRiskPasswordPageService,
|
||||||
@ -59,7 +68,7 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: "./at-risk-passwords.component.html",
|
templateUrl: "./at-risk-passwords.component.html",
|
||||||
})
|
})
|
||||||
export class AtRiskPasswordsComponent {
|
export class AtRiskPasswordsComponent implements OnInit {
|
||||||
private taskService = inject(TaskService);
|
private taskService = inject(TaskService);
|
||||||
private organizationService = inject(OrganizationService);
|
private organizationService = inject(OrganizationService);
|
||||||
private cipherService = inject(CipherService);
|
private cipherService = inject(CipherService);
|
||||||
@ -72,6 +81,7 @@ export class AtRiskPasswordsComponent {
|
|||||||
private atRiskPasswordPageService = inject(AtRiskPasswordPageService);
|
private atRiskPasswordPageService = inject(AtRiskPasswordPageService);
|
||||||
private changeLoginPasswordService = inject(ChangeLoginPasswordService);
|
private changeLoginPasswordService = inject(ChangeLoginPasswordService);
|
||||||
private platformUtilsService = inject(PlatformUtilsService);
|
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.
|
* 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) {
|
async viewCipher(cipher: CipherView) {
|
||||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||||
if (!repromptPassed) {
|
if (!repromptPassed) {
|
||||||
|
@ -199,3 +199,4 @@ export const NEW_DEVICE_VERIFICATION_NOTICE = new StateDefinition(
|
|||||||
);
|
);
|
||||||
export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
|
export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
|
||||||
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
|
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
|
||||||
|
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
|
||||||
|
@ -3,10 +3,12 @@
|
|||||||
@fadeIn
|
@fadeIn
|
||||||
>
|
>
|
||||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2 tw-px-4 tw-pt-4 tw-text-center">
|
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2 tw-px-4 tw-pt-4 tw-text-center">
|
||||||
@if (hasIcon) {
|
@if (!hideIcon()) {
|
||||||
<ng-content select="[bitDialogIcon]"></ng-content>
|
@if (hasIcon) {
|
||||||
} @else {
|
<ng-content select="[bitDialogIcon]"></ng-content>
|
||||||
<i class="bwi bwi-exclamation-triangle tw-text-3xl tw-text-warning" aria-hidden="true"></i>
|
} @else {
|
||||||
|
<i class="bwi bwi-exclamation-triangle tw-text-3xl tw-text-warning" aria-hidden="true"></i>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
<h1
|
<h1
|
||||||
bitDialogTitleContainer
|
bitDialogTitleContainer
|
||||||
|
@ -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 { TypographyDirective } from "../../typography/typography.directive";
|
||||||
import { fadeIn } from "../animations";
|
import { fadeIn } from "../animations";
|
||||||
@ -20,6 +20,11 @@ export class IconDirective {}
|
|||||||
export class SimpleDialogComponent {
|
export class SimpleDialogComponent {
|
||||||
@ContentChild(IconDirective) icon!: IconDirective;
|
@ContentChild(IconDirective) icon!: IconDirective;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional flag to hide the dialog's center icon. Defaults to false.
|
||||||
|
*/
|
||||||
|
hideIcon = input(false, { transform: booleanAttribute });
|
||||||
|
|
||||||
get hasIcon() {
|
get hasIcon() {
|
||||||
return this.icon != null;
|
return this.icon != null;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||||
|
|
||||||
import { ButtonModule } from "../../button";
|
import { ButtonModule } from "../../button";
|
||||||
import { DialogModule } from "../dialog.module";
|
import { DialogModule } from "../dialog.module";
|
||||||
@ -57,8 +57,24 @@ export const CustomIcon: Story = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const HideIcon: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<bit-simple-dialog hideIcon>
|
||||||
|
<span bitDialogTitle>Premium Subscription Available</span>
|
||||||
|
<span bitDialogContent> Message Content</span>
|
||||||
|
<ng-container bitDialogFooter>
|
||||||
|
<button bitButton buttonType="primary">Yes</button>
|
||||||
|
<button bitButton buttonType="secondary">No</button>
|
||||||
|
</ng-container>
|
||||||
|
</bit-simple-dialog>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export const ScrollingContent: Story = {
|
export const ScrollingContent: Story = {
|
||||||
render: (args: SimpleDialogComponent) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
template: `
|
template: `
|
||||||
<bit-simple-dialog>
|
<bit-simple-dialog>
|
||||||
|
@ -20,6 +20,6 @@
|
|||||||
></vault-carousel-button>
|
></vault-carousel-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tw-absolute tw-invisible" #tempSlideContainer *ngIf="minHeight === null">
|
<div class="tw-absolute tw-invisible" #tempSlideContainer *ngIf="minHeight === null">
|
||||||
<ng-template [cdkPortalOutlet] #tempSlideOutlet></ng-template>
|
<ng-template cdkPortalOutlet></ng-template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@ -8,12 +8,12 @@ import {
|
|||||||
ContentChildren,
|
ContentChildren,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
|
inject,
|
||||||
Input,
|
Input,
|
||||||
Output,
|
Output,
|
||||||
QueryList,
|
QueryList,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
inject,
|
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
|
|
||||||
import { ButtonModule } from "@bitwarden/components";
|
import { ButtonModule } from "@bitwarden/components";
|
||||||
@ -89,7 +89,7 @@ export class VaultCarouselComponent implements AfterViewInit {
|
|||||||
this.slideChange.emit(index);
|
this.slideChange.emit(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
async ngAfterViewInit() {
|
||||||
this.keyManager = new FocusKeyManager(this.carouselButtons)
|
this.keyManager = new FocusKeyManager(this.carouselButtons)
|
||||||
.withHorizontalOrientation("ltr")
|
.withHorizontalOrientation("ltr")
|
||||||
.withWrap()
|
.withWrap()
|
||||||
@ -98,7 +98,7 @@ export class VaultCarouselComponent implements AfterViewInit {
|
|||||||
// Set the first carousel button as active, this avoids having to double tab the arrow keys on initial focus.
|
// Set the first carousel button as active, this avoids having to double tab the arrow keys on initial focus.
|
||||||
this.keyManager.setFirstItemActive();
|
this.keyManager.setFirstItemActive();
|
||||||
|
|
||||||
this.setMinHeightOfCarousel();
|
await this.setMinHeightOfCarousel();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -106,7 +106,7 @@ export class VaultCarouselComponent implements AfterViewInit {
|
|||||||
* Render each slide in a temporary portal outlet to get the height of each slide
|
* Render each slide in a temporary portal outlet to get the height of each slide
|
||||||
* and store the tallest slide height.
|
* and store the tallest slide height.
|
||||||
*/
|
*/
|
||||||
private setMinHeightOfCarousel() {
|
private async setMinHeightOfCarousel() {
|
||||||
// Store the height of the carousel button element.
|
// Store the height of the carousel button element.
|
||||||
const heightOfButtonsPx = this.carouselButtonWrapper.nativeElement.offsetHeight;
|
const heightOfButtonsPx = this.carouselButtonWrapper.nativeElement.offsetHeight;
|
||||||
|
|
||||||
@ -121,13 +121,14 @@ export class VaultCarouselComponent implements AfterViewInit {
|
|||||||
// to determine the height of the first slide.
|
// to determine the height of the first slide.
|
||||||
let tallestSlideHeightPx = containerHeight - heightOfButtonsPx;
|
let tallestSlideHeightPx = containerHeight - heightOfButtonsPx;
|
||||||
|
|
||||||
this.slides.forEach((slide, index) => {
|
for (let i = 0; i < this.slides.length; i++) {
|
||||||
// Skip the first slide, the height is accounted for above.
|
if (i === this.selectedIndex) {
|
||||||
if (index === this.selectedIndex) {
|
continue;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
this.tempSlideOutlet.attach(this.slides.get(i)!.content);
|
||||||
|
|
||||||
this.tempSlideOutlet.attach(slide.content);
|
// Wait for the slide to render. Otherwise, the previous slide may not have been removed from the DOM yet.
|
||||||
|
await new Promise(requestAnimationFrame);
|
||||||
|
|
||||||
// Store the height of the current slide if is larger than the current stored height;
|
// Store the height of the current slide if is larger than the current stored height;
|
||||||
if (this.tempSlideContainer.nativeElement.offsetHeight > tallestSlideHeightPx) {
|
if (this.tempSlideContainer.nativeElement.offsetHeight > tallestSlideHeightPx) {
|
||||||
@ -136,8 +137,7 @@ export class VaultCarouselComponent implements AfterViewInit {
|
|||||||
|
|
||||||
// cleanup the outlet
|
// cleanup the outlet
|
||||||
this.tempSlideOutlet.detach();
|
this.tempSlideOutlet.detach();
|
||||||
});
|
}
|
||||||
|
|
||||||
// Set the min height of the entire carousel based on the largest slide.
|
// Set the min height of the entire carousel based on the largest slide.
|
||||||
this.minHeight = `${tallestSlideHeightPx + heightOfButtonsPx}px`;
|
this.minHeight = `${tallestSlideHeightPx + heightOfButtonsPx}px`;
|
||||||
this.changeDetectorRef.detectChanges();
|
this.changeDetectorRef.detectChanges();
|
||||||
|
10
libs/vault/src/components/carousel/carousel.module.ts
Normal file
10
libs/vault/src/components/carousel/carousel.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { NgModule } from "@angular/core";
|
||||||
|
|
||||||
|
import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component";
|
||||||
|
import { VaultCarouselComponent } from "./carousel.component";
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [VaultCarouselComponent, VaultCarouselSlideComponent],
|
||||||
|
exports: [VaultCarouselComponent, VaultCarouselSlideComponent],
|
||||||
|
})
|
||||||
|
export class VaultCarouselModule {}
|
@ -1 +1 @@
|
|||||||
export { VaultCarouselComponent } from "./carousel.component";
|
export { VaultCarouselModule } from "./carousel.module";
|
||||||
|
62
libs/vault/src/components/dark-image-source.directive.ts
Normal file
62
libs/vault/src/components/dark-image-source.directive.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
DestroyRef,
|
||||||
|
Directive,
|
||||||
|
ElementRef,
|
||||||
|
HostBinding,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
OnInit,
|
||||||
|
} from "@angular/core";
|
||||||
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
|
import { combineLatest, Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
|
||||||
|
import { Theme } from "@bitwarden/common/platform/enums";
|
||||||
|
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directive that will switch the image source based on the currently applied theme.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```html
|
||||||
|
* <img src="light-image.png" appDarkImgSrc="dark-image.png" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Directive({
|
||||||
|
selector: "[appDarkImgSrc]",
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class DarkImageSourceDirective implements OnInit {
|
||||||
|
private themeService = inject(ThemeStateService);
|
||||||
|
private systemTheme$: Observable<Theme> = inject(SYSTEM_THEME_OBSERVABLE);
|
||||||
|
private el = inject(ElementRef<HTMLElement>);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The image source to use when the light theme is applied. Automatically assigned the value
|
||||||
|
* of the `<img>` src attribute.
|
||||||
|
*/
|
||||||
|
protected lightImgSrc: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The image source to use when the dark theme is applied.
|
||||||
|
*/
|
||||||
|
darkImgSrc = input.required<string>({ alias: "appDarkImgSrc" });
|
||||||
|
|
||||||
|
@HostBinding("attr.src") src: string | undefined;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
// Set the light image source from the element's current src attribute
|
||||||
|
this.lightImgSrc = this.el.nativeElement.getAttribute("src");
|
||||||
|
|
||||||
|
// Update the image source based on the active theme
|
||||||
|
combineLatest([this.themeService.selectedTheme$, this.systemTheme$])
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe(([theme, systemTheme]) => {
|
||||||
|
const appliedTheme = theme === "system" ? systemTheme : theme;
|
||||||
|
const isDark =
|
||||||
|
appliedTheme === "dark" || appliedTheme === "nord" || appliedTheme === "solarizedDark";
|
||||||
|
this.src = isDark ? this.darkImgSrc() : this.lightImgSrc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,7 @@ export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field
|
|||||||
export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive";
|
export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive";
|
||||||
export { OrgIconDirective } from "./components/org-icon.directive";
|
export { OrgIconDirective } from "./components/org-icon.directive";
|
||||||
export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive";
|
export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive";
|
||||||
|
export { DarkImageSourceDirective } from "./components/dark-image-source.directive";
|
||||||
|
|
||||||
export * from "./utils/observable-utilities";
|
export * from "./utils/observable-utilities";
|
||||||
|
|
||||||
@ -21,6 +22,7 @@ export { NewDeviceVerificationNoticePageOneComponent } from "./components/new-de
|
|||||||
export { NewDeviceVerificationNoticePageTwoComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-two.component";
|
export { NewDeviceVerificationNoticePageTwoComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-two.component";
|
||||||
export { DecryptionFailureDialogComponent } from "./components/decryption-failure-dialog/decryption-failure-dialog.component";
|
export { DecryptionFailureDialogComponent } from "./components/decryption-failure-dialog/decryption-failure-dialog.component";
|
||||||
export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.component";
|
export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.component";
|
||||||
|
export * from "./components/carousel";
|
||||||
|
|
||||||
export * as VaultIcons from "./icons";
|
export * as VaultIcons from "./icons";
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user