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:
@@ -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 what’s 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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
54
apps/browser/src/vault/guards/intro-carousel.guard.spec.ts
Normal file
54
apps/browser/src/vault/guards/intro-carousel.guard.spec.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
26
apps/browser/src/vault/guards/intro-carousel.guard.ts
Normal file
26
apps/browser/src/vault/guards/intro-carousel.guard.ts
Normal 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"]);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user