diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding-task.component.html b/apps/web/src/app/shared/components/onboarding/onboarding-task.component.html similarity index 90% rename from bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding-task.component.html rename to apps/web/src/app/shared/components/onboarding/onboarding-task.component.html index da6b3d697b..6623ac4afb 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding-task.component.html +++ b/apps/web/src/app/shared/components/onboarding/onboarding-task.component.html @@ -12,7 +12,7 @@ -
({ ...args, }, template: ` - - + ({ {{ "downloadThe" | i18n }} {{ "smCLI" | i18n }} - - + - + - + - + > + `, }); diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/services/abstraction/vault-onboarding.service.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/services/abstraction/vault-onboarding.service.ts new file mode 100644 index 0000000000..b1d1b4efbf --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/services/abstraction/vault-onboarding.service.ts @@ -0,0 +1,8 @@ +import { Observable } from "rxjs"; + +import { VaultOnboardingTasks } from "../vault-onboarding.service"; + +export abstract class VaultOnboardingService { + vaultOnboardingState$: Observable; + abstract setVaultOnboardingTasks(newState: VaultOnboardingTasks): Promise; +} diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/services/vault-onboarding.service.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/services/vault-onboarding.service.ts new file mode 100644 index 0000000000..927de00737 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/services/vault-onboarding.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from "@angular/core"; +import { Observable } from "rxjs"; + +import { + ActiveUserState, + KeyDefinition, + StateProvider, + VAULT_ONBOARDING, +} from "@bitwarden/common/platform/state"; + +import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./abstraction/vault-onboarding.service"; + +export type VaultOnboardingTasks = { + createAccount: boolean; + importData: boolean; + installExtension: boolean; +}; + +const VAULT_ONBOARDING_KEY = new KeyDefinition(VAULT_ONBOARDING, "tasks", { + deserializer: (jsonData) => jsonData, +}); + +@Injectable() +export class VaultOnboardingService implements VaultOnboardingServiceAbstraction { + private vaultOnboardingState: ActiveUserState; + vaultOnboardingState$: Observable; + + constructor(private stateProvider: StateProvider) { + this.vaultOnboardingState = this.stateProvider.getActive(VAULT_ONBOARDING_KEY); + this.vaultOnboardingState$ = this.vaultOnboardingState.state$; + } + + async setVaultOnboardingTasks(newState: VaultOnboardingTasks): Promise { + await this.vaultOnboardingState.update(() => { + return { ...newState }; + }); + } +} diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.html b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.html new file mode 100644 index 0000000000..a6a71c3df8 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.html @@ -0,0 +1,44 @@ +
+ + + + +

+ {{ "onboardingImportDataDetailsPartOne" | i18n }} + + {{ "onboardingImportDataDetailsPartTwo" | i18n }} +

+
+ + + + {{ "installBrowserExtensionDetails" | i18n }} + + +
+
diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts new file mode 100644 index 0000000000..6bf9609898 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts @@ -0,0 +1,146 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { RouterTestingModule } from "@angular/router/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; + +import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./services/abstraction/vault-onboarding.service"; +import { VaultOnboardingComponent } from "./vault-onboarding.component"; + +describe("VaultOnboardingComponent", () => { + let component: VaultOnboardingComponent; + let fixture: ComponentFixture; + let mockPlatformUtilsService: Partial; + let mockApiService: Partial; + let mockPolicyService: MockProxy; + let mockI18nService: MockProxy; + let mockConfigService: MockProxy; + let mockVaultOnboardingService: MockProxy; + let mockStateProvider: Partial; + let setInstallExtLinkSpy: any; + let individualVaultPolicyCheckSpy: any; + + beforeEach(() => { + mockPolicyService = mock(); + mockI18nService = mock(); + mockPlatformUtilsService = mock(); + mockApiService = { + getProfile: jest.fn(), + }; + mockConfigService = mock(); + mockVaultOnboardingService = mock(); + mockStateProvider = { + getActive: jest.fn().mockReturnValue( + of({ + vaultTasks: { + createAccount: true, + importData: false, + installExtension: false, + }, + }), + ), + }; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + TestBed.configureTestingModule({ + declarations: [], + imports: [RouterTestingModule], + providers: [ + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: PolicyService, useValue: mockPolicyService }, + { provide: VaultOnboardingServiceAbstraction, useValue: mockVaultOnboardingService }, + { provide: I18nService, useValue: mockI18nService }, + { provide: ApiService, useValue: mockApiService }, + { provide: ConfigServiceAbstraction, useValue: mockConfigService }, + { provide: StateProvider, useValue: mockStateProvider }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(VaultOnboardingComponent); + component = fixture.componentInstance; + setInstallExtLinkSpy = jest.spyOn(component, "setInstallExtLink"); + individualVaultPolicyCheckSpy = jest + .spyOn(component, "individualVaultPolicyCheck") + .mockReturnValue(undefined); + jest.spyOn(component, "checkCreationDate").mockReturnValue(null); + (component as any).vaultOnboardingService.vaultOnboardingState$ = of({ + createAccount: true, + importData: false, + installExtension: false, + }); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("ngOnInit", () => { + it("should call setInstallExtLink", async () => { + await component.ngOnInit(); + expect(setInstallExtLinkSpy).toHaveBeenCalled(); + }); + + it("should call individualVaultPolicyCheck", async () => { + await component.ngOnInit(); + expect(individualVaultPolicyCheckSpy).toHaveBeenCalled(); + }); + }); + + describe("show and hide onboarding component", () => { + it("should set showOnboarding to true", async () => { + await component.ngOnInit(); + expect((component as any).showOnboarding).toBe(true); + }); + + it("should set showOnboarding to false if dismiss is clicked", async () => { + await component.ngOnInit(); + (component as any).hideOnboarding(); + expect((component as any).showOnboarding).toBe(false); + }); + }); + + describe("setInstallExtLink", () => { + it("should set extensionUrl to Chrome Web Store when isChrome is true", async () => { + jest.spyOn((component as any).platformUtilsService, "isChrome").mockReturnValue(true); + const expected = + "https://chrome.google.com/webstore/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb"; + await component.ngOnInit(); + expect(component.extensionUrl).toEqual(expected); + }); + + it("should set extensionUrl to Firefox Store when isFirefox is true", async () => { + jest.spyOn((component as any).platformUtilsService, "isFirefox").mockReturnValue(true); + const expected = "https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/"; + await component.ngOnInit(); + expect(component.extensionUrl).toEqual(expected); + }); + + it("should set extensionUrl when isSafari is true", async () => { + jest.spyOn((component as any).platformUtilsService, "isSafari").mockReturnValue(true); + const expected = "https://apps.apple.com/us/app/bitwarden/id1352778147?mt=12"; + await component.ngOnInit(); + expect(component.extensionUrl).toEqual(expected); + }); + }); + + describe("individualVaultPolicyCheck", () => { + it("should set isIndividualPolicyVault to true", async () => { + individualVaultPolicyCheckSpy.mockRestore(); + const spy = jest + .spyOn((component as any).policyService, "policyAppliesToActiveUser$") + .mockReturnValue(of(true)); + + await component.individualVaultPolicyCheck(); + fixture.detectChanges(); + expect(spy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts new file mode 100644 index 0000000000..1243c5b083 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts @@ -0,0 +1,165 @@ +import { CommonModule } from "@angular/common"; +import { + Component, + OnInit, + Input, + Output, + EventEmitter, + OnDestroy, + SimpleChanges, + OnChanges, +} from "@angular/core"; +import { Router } from "@angular/router"; +import { Subject, takeUntil, Observable, firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LinkModule } from "@bitwarden/components"; + +import { OnboardingModule } from "../../../shared/components/onboarding/onboarding.module"; + +import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./services/abstraction/vault-onboarding.service"; +import { VaultOnboardingTasks } from "./services/vault-onboarding.service"; + +@Component({ + standalone: true, + imports: [OnboardingModule, CommonModule, JslibModule, LinkModule], + selector: "app-vault-onboarding", + templateUrl: "vault-onboarding.component.html", +}) +export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { + @Input() ciphers: CipherView[]; + @Output() onAddCipher = new EventEmitter(); + + extensionUrl: string; + isIndividualPolicyVault: boolean; + private destroy$ = new Subject(); + isNewAccount: boolean; + private readonly onboardingReleaseDate = new Date("2024-01-01"); + showOnboardingAccess$: Observable; + + protected currentTasks: VaultOnboardingTasks; + + protected onboardingTasks$: Observable; + protected showOnboarding = false; + + constructor( + protected platformUtilsService: PlatformUtilsService, + protected policyService: PolicyService, + protected router: Router, + private apiService: ApiService, + private configService: ConfigServiceAbstraction, + private vaultOnboardingService: VaultOnboardingServiceAbstraction, + ) {} + + async ngOnInit() { + this.showOnboardingAccess$ = await this.configService.getFeatureFlag$( + FeatureFlag.VaultOnboarding, + false, + ); + this.onboardingTasks$ = this.vaultOnboardingService.vaultOnboardingState$; + await this.setOnboardingTasks(); + this.setInstallExtLink(); + this.individualVaultPolicyCheck(); + } + + async ngOnChanges(changes: SimpleChanges) { + if (this.showOnboarding && changes?.ciphers) { + await this.saveCompletedTasks({ + createAccount: true, + importData: this.ciphers.length > 0, + installExtension: false, + }); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + async checkCreationDate() { + const userProfile = await this.apiService.getProfile(); + const profileCreationDate = new Date(userProfile.creationDate); + + this.isNewAccount = this.onboardingReleaseDate < profileCreationDate ? true : false; + + if (!this.isNewAccount) { + await this.hideOnboarding(); + } + } + + protected async hideOnboarding() { + await this.saveCompletedTasks({ + createAccount: true, + importData: true, + installExtension: true, + }); + } + + async setOnboardingTasks() { + const currentTasks = await firstValueFrom(this.onboardingTasks$); + if (currentTasks == null) { + const freshStart = { + createAccount: true, + importData: this.ciphers?.length > 0, + installExtension: false, + }; + await this.saveCompletedTasks(freshStart); + } else if (currentTasks) { + this.showOnboarding = Object.values(currentTasks).includes(false); + } + + if (this.showOnboarding) { + await this.checkCreationDate(); + } + } + + private async saveCompletedTasks(vaultTasks: VaultOnboardingTasks) { + this.showOnboarding = Object.values(vaultTasks).includes(false); + await this.vaultOnboardingService.setVaultOnboardingTasks(vaultTasks); + } + + individualVaultPolicyCheck() { + this.policyService + .policyAppliesToActiveUser$(PolicyType.PersonalOwnership) + .pipe(takeUntil(this.destroy$)) + .subscribe((data) => { + this.isIndividualPolicyVault = data; + }); + } + + emitToAddCipher() { + this.onAddCipher.emit(); + } + + setInstallExtLink() { + if (this.platformUtilsService.isChrome()) { + this.extensionUrl = + "https://chrome.google.com/webstore/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb"; + } else if (this.platformUtilsService.isFirefox()) { + this.extensionUrl = + "https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/"; + } else if (this.platformUtilsService.isSafari()) { + this.extensionUrl = "https://apps.apple.com/us/app/bitwarden/id1352778147?mt=12"; + } else if (this.platformUtilsService.isOpera()) { + this.extensionUrl = + "https://addons.opera.com/extensions/details/bitwarden-free-password-manager/"; + } else if (this.platformUtilsService.isEdge()) { + this.extensionUrl = + "https://microsoftedge.microsoft.com/addons/detail/jbkfoedolllekgbhcbcoahefnbanhhlh"; + } else { + this.extensionUrl = "https://bitwarden.com/download/#downloads-web-browser"; + } + } + + navigateToExtension() { + window.open(this.extensionUrl, "_blank"); + } +} diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index 4c009166f1..0e7b7afbdb 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -1,4 +1,6 @@
+ +
diff --git a/apps/web/src/app/vault/individual-vault/vault.module.ts b/apps/web/src/app/vault/individual-vault/vault.module.ts index e584cae701..81fc38eda1 100644 --- a/apps/web/src/app/vault/individual-vault/vault.module.ts +++ b/apps/web/src/app/vault/individual-vault/vault.module.ts @@ -13,6 +13,9 @@ import { OrganizationBadgeModule } from "./organization-badge/organization-badge import { PipesModule } from "./pipes/pipes.module"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultHeaderComponent } from "./vault-header/vault-header.component"; +import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./vault-onboarding/services/abstraction/vault-onboarding.service"; +import { VaultOnboardingService } from "./vault-onboarding/services/vault-onboarding.service"; +import { VaultOnboardingComponent } from "./vault-onboarding/vault-onboarding.component"; import { VaultRoutingModule } from "./vault-routing.module"; import { VaultComponent } from "./vault.component"; @@ -30,8 +33,15 @@ import { VaultComponent } from "./vault.component"; BreadcrumbsModule, VaultItemsModule, CollectionDialogModule, + VaultOnboardingComponent, ], declarations: [VaultComponent, VaultHeaderComponent], exports: [VaultComponent], + providers: [ + { + provide: VaultOnboardingServiceAbstraction, + useClass: VaultOnboardingService, + }, + ], }) export class VaultModule {} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 7cc35b67c3..b965a176ab 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1347,6 +1347,18 @@ "importData": { "message": "Import data" }, + "onboardingImportDataDetailsPartOne": { + "message": "If you don't have any data to import, you can create a ", + "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. You may need to wait until your administrator confirms your organization membership." + }, + "onboardingImportDataDetailsLink": { + "message": "new item", + "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. You may need to wait until your administrator confirms your organization membership." + }, + "onboardingImportDataDetailsPartTwo": { + "message": " instead. You may need to wait until your administrator confirms your organization membership.", + "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. You may need to wait until your administrator confirms your organization membership." + }, "importError": { "message": "Import error" }, @@ -6912,6 +6924,9 @@ "message": "SDK", "description": "Software Development Kit" }, + "createAnAccount": { + "message": "Create an account" + }, "createSecret": { "message": "Create a secret" }, @@ -7456,6 +7471,12 @@ "message": "See detailed instructions on our help site at", "description": "This is followed a by a hyperlink to the help website." }, + "installBrowserExtension": { + "message": "Install browser extension" + }, + "installBrowserExtensionDetails": { + "message": "Use the extension to quickly save logins and auto-fill forms without opening the web app." + }, "projectAccessUpdated": { "message": "Project access updated" }, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html index f097481633..6dec4f6f90 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html @@ -3,8 +3,8 @@
- - + - - + - + - + - + > +
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts index a526075518..72039f532a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts @@ -1,8 +1,8 @@ import { NgModule } from "@angular/core"; +import { OnboardingModule } from "../../../../../../apps/web/src/app/shared/components/onboarding/onboarding.module"; import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; -import { OnboardingModule } from "./onboarding.module"; import { OverviewRoutingModule } from "./overview-routing.module"; import { OverviewComponent } from "./overview.component"; import { SectionComponent } from "./section.component"; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index bf5287801d..813078ca0a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -4,6 +4,7 @@ export enum FeatureFlag { ItemShare = "item-share", FlexibleCollectionsV1 = "flexible-collections-v-1", // v-1 is intentional BulkCollectionAccess = "bulk-collection-access", + VaultOnboarding = "vault-onboarding", GeneratorToolsModernization = "generator-tools-modernization", KeyRotationImprovements = "key-rotation-improvements", FlexibleCollectionsMigration = "flexible-collections-migration", diff --git a/libs/common/src/models/response/profile.response.ts b/libs/common/src/models/response/profile.response.ts index d39b39bc58..fbaa4f84ef 100644 --- a/libs/common/src/models/response/profile.response.ts +++ b/libs/common/src/models/response/profile.response.ts @@ -16,6 +16,7 @@ export class ProfileResponse extends BaseResponse { twoFactorEnabled: boolean; key: string; avatarColor: string; + creationDate: string; privateKey: string; securityStamp: string; forcePasswordReset: boolean; @@ -37,6 +38,7 @@ export class ProfileResponse extends BaseResponse { this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled"); this.key = this.getResponseProperty("Key"); this.avatarColor = this.getResponseProperty("AvatarColor"); + this.creationDate = this.getResponseProperty("CreationDate"); this.privateKey = this.getResponseProperty("PrivateKey"); this.securityStamp = this.getResponseProperty("SecurityStamp"); this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset") ?? false; diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 270a102c8f..5b98c30bed 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -27,6 +27,10 @@ export const SSO_DISK = new StateDefinition("ssoLogin", "disk"); export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk"); +export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", { + web: "disk-local", +}); + export const GENERATOR_DISK = new StateDefinition("generator", "disk"); export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");