1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[PM-18790] browser intro carousel component (#14097)

* build intro carousel for browser to show for new installations
This commit is contained in:
Jason Ng
2025-04-04 10:52:18 -04:00
committed by GitHub
parent 3a4f342471
commit 263598d9e0
16 changed files with 333 additions and 3 deletions

View File

@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
},
"introCarouselLabel": {
"message": "Welcome to Bitwarden"
},
"securityPrioritized": {
"message": "Security, prioritized"
},
"securityPrioritizedBody": {
"message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect whats important to you."
},
"quickLogin": {
"message": "Quick and easy login"
},
"quickLoginBody": {
"message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
},
"secureUser": {
"message": "Level up your logins"
},
"secureUserBody": {
"message": "Use the generator to create and save strong, unique passwords for all your accounts."
},
"secureDevices": {
"message": "Your data, when and where you need it"
},
"secureDevicesBody": {
"message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}

View File

@@ -79,10 +79,12 @@ import { ImportBrowserV2Component } from "../tools/popup/settings/import/import-
import { SettingsV2Component } from "../tools/popup/settings/settings-v2.component";
import { canAccessAtRiskPasswords } from "../vault/guards/at-risk-passwords.guard";
import { clearVaultStateGuard } from "../vault/guards/clear-vault-state.guard";
import { IntroCarouselGuard } from "../vault/guards/intro-carousel.guard";
import { AtRiskPasswordsComponent } from "../vault/popup/components/at-risk-passwords/at-risk-passwords.component";
import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component";
import { AssignCollections } from "../vault/popup/components/vault-v2/assign-collections/assign-collections.component";
import { AttachmentsV2Component } from "../vault/popup/components/vault-v2/attachments/attachments-v2.component";
import { IntroCarouselComponent } from "../vault/popup/components/vault-v2/intro-carousel/intro-carousel.component";
import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component";
import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component";
import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component";
@@ -383,7 +385,7 @@ const routes: Routes = [
},
{
path: "login",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
canActivate: [unauthGuardFn(unauthRouteOverrides), IntroCarouselGuard],
data: {
pageIcon: VaultIcon,
pageTitle: {
@@ -587,6 +589,21 @@ const routes: Routes = [
canActivate: [authGuard],
data: { elevation: 2 } satisfies RouteDataProperties,
},
{
path: "intro-carousel",
component: ExtensionAnonLayoutWrapperComponent,
canActivate: [],
children: [
{
path: "",
component: IntroCarouselComponent,
data: {
hideIcon: true,
hideFooter: true,
},
},
],
},
{
path: "new-device-notice",
component: ExtensionAnonLayoutWrapperComponent,

View File

@@ -0,0 +1,54 @@
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { IntroCarouselService } from "../popup/services/intro-carousel.service";
import { IntroCarouselGuard } from "./intro-carousel.guard";
describe("IntroCarouselGuard", () => {
let mockConfigService: MockProxy<ConfigService>;
const mockIntroCarouselService = {
introCarouselState$: of(true),
};
const createUrlTree = jest.fn();
beforeEach(() => {
mockConfigService = mock<ConfigService>();
createUrlTree.mockClear();
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: { createUrlTree } },
{ provide: ConfigService, useValue: mockConfigService },
{
provide: IntroCarouselService,
useValue: mockIntroCarouselService,
},
],
});
});
it("should return true if the feature flag is off", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
const result = await TestBed.runInInjectionContext(async () => await IntroCarouselGuard());
expect(result).toBe(true);
});
it("should navigate to intro-carousel route if feature flag is true and dismissed is true", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
const result = await TestBed.runInInjectionContext(async () => await IntroCarouselGuard());
expect(result).toBe(true);
});
it("should navigate to intro-carousel route if feature flag is true and dismissed is false", async () => {
TestBed.overrideProvider(IntroCarouselService, {
useValue: { introCarouselState$: of(false) },
});
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await TestBed.runInInjectionContext(async () => await IntroCarouselGuard());
expect(createUrlTree).toHaveBeenCalledWith(["/intro-carousel"]);
});
});

View File

@@ -0,0 +1,26 @@
import { inject } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { IntroCarouselService } from "../popup/services/intro-carousel.service";
export const IntroCarouselGuard = async () => {
const router = inject(Router);
const configService = inject(ConfigService);
const introCarouselService = inject(IntroCarouselService);
const hasOnboardingNudgesFlag = await configService.getFeatureFlag(
FeatureFlag.PM8851_BrowserOnboardingNudge,
);
const hasIntroCarouselDismissed = await firstValueFrom(introCarouselService.introCarouselState$);
if (!hasOnboardingNudgesFlag || hasIntroCarouselDismissed) {
return true;
}
return router.createUrlTree(["/intro-carousel"]);
};

View File

@@ -0,0 +1,49 @@
<vault-carousel [label]="'introCarouselLabel' | i18n">
<vault-carousel-slide [label]="'securityPrioritized' | i18n" [disablePadding]="true">
<div class="tw-flex tw-flex-col tw-items-center tw-justify-around">
<bit-icon [icon]="securityHandshake"></bit-icon>
<h2 bitTypography="h2" class="tw-text-center">{{ "securityPrioritized" | i18n }}</h2>
<p bitTypography="body1" class="tw-text-center">{{ "securityPrioritizedBody" | i18n }}</p>
</div>
</vault-carousel-slide>
<vault-carousel-slide [label]="'quickLogin' | i18n" [disablePadding]="true">
<div class="tw-flex tw-flex-col tw-items-center tw-justify-around">
<bit-icon [icon]="loginCards"></bit-icon>
<h2 bitTypography="h2" class="tw-text-center">{{ "quickLogin" | i18n }}</h2>
<p bitTypography="body1" class="tw-text-center">{{ "quickLoginBody" | i18n }}</p>
</div>
</vault-carousel-slide>
<vault-carousel-slide [label]="'secureUser' | i18n" [disablePadding]="true">
<div class="tw-flex tw-flex-col tw-items-center tw-justify-around">
<bit-icon [icon]="secureUser"></bit-icon>
<h2 bitTypography="h2" class="tw-text-center">{{ "secureUser" | i18n }}</h2>
<p bitTypography="body1" class="tw-text-center">{{ "secureUserBody" | i18n }}</p>
</div>
</vault-carousel-slide>
<vault-carousel-slide [label]="'secureDevices' | i18n" [disablePadding]="true">
<div class="tw-flex tw-flex-col tw-items-center tw-justify-around">
<bit-icon [icon]="secureDevices"></bit-icon>
<h2 bitTypography="h2" class="tw-text-center">{{ "secureDevices" | i18n }}</h2>
<p bitTypography="body1" class="tw-text-center">{{ "secureDevicesBody" | i18n }}</p>
</div>
</vault-carousel-slide>
</vault-carousel>
<button
type="button"
bitButton
buttonType="primary"
(click)="navigateToSignup()"
class="tw-w-full tw-mt-4"
>
{{ "createAccount" | i18n }}
</button>
<button
type="button"
bitButton
buttonType="secondary"
(click)="navigateToLogin()"
class="tw-w-full tw-mt-4"
>
{{ "logIn" | i18n }}
</button>

View File

@@ -0,0 +1,45 @@
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ButtonModule, DialogModule, IconModule, TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { VaultCarouselModule, VaultIcons } from "@bitwarden/vault";
import { IntroCarouselService } from "../../../services/intro-carousel.service";
@Component({
selector: "app-intro-carousel",
templateUrl: "./intro-carousel.component.html",
imports: [
VaultCarouselModule,
ButtonModule,
IconModule,
DialogModule,
TypographyModule,
JslibModule,
I18nPipe,
],
standalone: true,
})
export class IntroCarouselComponent {
protected securityHandshake = VaultIcons.SecurityHandshake;
protected loginCards = VaultIcons.LoginCards;
protected secureUser = VaultIcons.SecureUser;
protected secureDevices = VaultIcons.SecureDevices;
constructor(
private router: Router,
private introCarouselService: IntroCarouselService,
) {}
protected async navigateToSignup() {
await this.introCarouselService.setIntroCarouselDismissed();
await this.router.navigate(["/signup"]);
}
protected async navigateToLogin() {
await this.introCarouselService.setIntroCarouselDismissed();
await this.router.navigate(["/login"]);
}
}

View File

@@ -18,6 +18,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -28,6 +29,7 @@ import { CurrentAccountComponent } from "../../../../auth/popup/account-switchin
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 { IntroCarouselService } from "../../services/intro-carousel.service";
import { VaultPopupCopyButtonsService } from "../../services/vault-popup-copy-buttons.service";
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service";
@@ -128,6 +130,8 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
private cipherService: CipherService,
private dialogService: DialogService,
private vaultCopyButtonsService: VaultPopupCopyButtonsService,
private introCarouselService: IntroCarouselService,
private configService: ConfigService,
) {
combineLatest([
this.vaultPopupItemsService.emptyVault$,
@@ -165,6 +169,12 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
}
async ngOnInit() {
const hasVaultNudgeFlag = await this.configService.getFeatureFlag(
FeatureFlag.PM8851_BrowserOnboardingNudge,
);
if (hasVaultNudgeFlag) {
await this.introCarouselService.setIntroCarouselDismissed();
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.cipherService

View File

@@ -0,0 +1,34 @@
import { Injectable } from "@angular/core";
import { map, Observable } from "rxjs";
import {
GlobalState,
KeyDefinition,
StateProvider,
VAULT_BROWSER_INTRO_CAROUSEL,
} from "@bitwarden/common/platform/state";
const INTRO_CAROUSEL = new KeyDefinition<boolean>(
VAULT_BROWSER_INTRO_CAROUSEL,
"introCarouselDismissed",
{
deserializer: (dismissed) => dismissed,
},
);
@Injectable({
providedIn: "root",
})
export class IntroCarouselService {
private introCarouselState: GlobalState<boolean> = this.stateProvider.getGlobal(INTRO_CAROUSEL);
readonly introCarouselState$: Observable<boolean> = this.introCarouselState.state$.pipe(
map((x) => x ?? false),
);
constructor(private stateProvider: StateProvider) {}
async setIntroCarouselDismissed(): Promise<void> {
await this.introCarouselState.update(() => true);
}
}