1
0
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:
Shane Melton 2025-02-26 13:24:35 -08:00 committed by GitHub
parent 9aee5f16c4
commit b9ebf0704a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 334 additions and 30 deletions

View File

@ -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 Bitwardens 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

View File

@ -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>

View File

@ -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,
});
}
}

View File

@ -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);
} }
} }

View File

@ -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();
});
});
}); });

View File

@ -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) {

View File

@ -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");

View File

@ -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

View File

@ -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;
} }

View File

@ -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>

View File

@ -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>

View File

@ -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();

View 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 {}

View File

@ -1 +1 @@
export { VaultCarouselComponent } from "./carousel.component"; export { VaultCarouselModule } from "./carousel.module";

View 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;
});
}
}

View File

@ -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";