diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 8170f1eef00..ddb87320839 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1037,11 +1037,7 @@ jobs: --type macos \ --file "$(find ./dist/mas-universal/Bitwarden*.pkg)" \ --apiKey $APP_STORE_CONNECT_AUTH_KEY \ - --apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER \ - &> output.txt - - UUID=$(cat output.txt | grep "Delivery UUID" | sed -E 's/Delivery UUID: (.*)/\1/') - echo "uuid=$UUID" >> $GITHUB_OUTPUT + --apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER - name: Post message to a Slack channel id: slack-message @@ -1059,24 +1055,14 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": "Desktop client v${{ env._PACKAGE_VERSION }} <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|build> success on *${{ github.ref_name }}*" - }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "TestFlight Build", - "emoji": true - }, - "url": "https://appstoreconnect.apple.com/teams/${{ env.APP_STORE_CONNECT_TEAM_ISSUER }}/apps/1352778147/testflight/macos/${{ env.BUILD_UUID }}" + "text": "Desktop client v${{ env._PACKAGE_VERSION }} <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|build ${{ env.BUILD_NUMBER }}> success on *${{ github.ref_name }}*" } } ] } env: - APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }} SLACK_BOT_TOKEN: ${{ steps.retrieve-slack-secret.outputs.slack-bot-token }} - BUILD_UUID: ${{ steps.testflight-deploy.outputs.uuid }} + BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} macos-package-dev: diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index a57b65f6982..061929fc49c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1136,6 +1136,9 @@ "file": { "message": "File" }, + "fileToShare": { + "message": "File to share" + }, "selectFile": { "message": "Select a file" }, diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts index 48e6cbb8a31..49526bb032b 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -117,25 +117,18 @@ export class SendAddEditComponent { ) .subscribe((config) => { this.config = config; - this.headerText = this.getHeaderText(config.mode, config.sendType); + this.headerText = this.getHeaderText(config.mode); }); } /** - * Gets the header text based on the mode and type. + * Gets the header text based on the mode. * @param mode The mode of the send form. - * @param type The type of the send form. * @returns The header text. */ - private getHeaderText(mode: SendFormMode, type: SendType) { - const headerKey = - mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader"; - - switch (type) { - case SendType.Text: - return this.i18nService.t(headerKey, this.i18nService.t("sendTypeText")); - case SendType.File: - return this.i18nService.t(headerKey, this.i18nService.t("sendTypeFile")); - } + private getHeaderText(mode: SendFormMode) { + return this.i18nService.t( + mode === "edit" || mode === "partial-edit" ? "editSend" : "createSend", + ); } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index b2ef6701b42..a640abe69f6 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -27,18 +27,22 @@ import { ToastService, } from "@bitwarden/components"; +import { PremiumUpgradePromptService } from "../../../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service"; import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view"; import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component"; - -import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component"; -import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component"; -import { PopupPageComponent } from "./../../../../../platform/popup/layout/popup-page.component"; -import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service"; +import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; +import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service"; +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; @Component({ selector: "app-view-v2", templateUrl: "view-v2.component.html", standalone: true, + providers: [ + { provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService }, + ], imports: [ CommonModule, SearchModule, diff --git a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts new file mode 100644 index 00000000000..9a00bacd6b0 --- /dev/null +++ b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts @@ -0,0 +1,26 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { BrowserPremiumUpgradePromptService } from "./browser-premium-upgrade-prompt.service"; + +describe("BrowserPremiumUpgradePromptService", () => { + let service: BrowserPremiumUpgradePromptService; + let router: MockProxy; + + beforeEach(async () => { + router = mock(); + await TestBed.configureTestingModule({ + providers: [BrowserPremiumUpgradePromptService, { provide: Router, useValue: router }], + }).compileComponents(); + + service = TestBed.inject(BrowserPremiumUpgradePromptService); + }); + + describe("promptForPremium", () => { + it("navigates to the premium update screen", async () => { + await service.promptForPremium(); + expect(router.navigate).toHaveBeenCalledWith(["/premium"]); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts new file mode 100644 index 00000000000..2909e3b3bd6 --- /dev/null +++ b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts @@ -0,0 +1,18 @@ +import { inject } from "@angular/core"; +import { Router } from "@angular/router"; + +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; + +/** + * This class handles the premium upgrade process for the browser extension. + */ +export class BrowserPremiumUpgradePromptService implements PremiumUpgradePromptService { + private router = inject(Router); + + async promptForPremium() { + /** + * Navigate to the premium update screen. + */ + await this.router.navigate(["/premium"]); + } +} diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index 49e414dfe99..09783f26f49 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -25,7 +25,7 @@ "**/node_modules/argon2/package.json", "**/node_modules/argon2/build/Release/argon2.node" ], - "electronVersion": "32.0.2", + "electronVersion": "32.1.1", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", diff --git a/apps/desktop/src/main/menu/menu.file.ts b/apps/desktop/src/main/menu/menu.file.ts index 562bc1fd686..712e579515e 100644 --- a/apps/desktop/src/main/menu/menu.file.ts +++ b/apps/desktop/src/main/menu/menu.file.ts @@ -120,7 +120,7 @@ export class FileMenu extends FirstMenu implements IMenubarMenu { id: "syncVault", label: this.localize("syncVault"), click: () => this.sendMessage("syncVault"), - enabled: !this._isLocked, + enabled: this.hasAuthenticatedAccounts, }; } diff --git a/apps/desktop/src/main/menu/menu.first.ts b/apps/desktop/src/main/menu/menu.first.ts index d9575bb2331..6407cc8aff8 100644 --- a/apps/desktop/src/main/menu/menu.first.ts +++ b/apps/desktop/src/main/menu/menu.first.ts @@ -27,6 +27,10 @@ export class FirstMenu { return this._accounts != null && Object.values(this._accounts).some((a) => a.isLockable); } + protected get hasAuthenticatedAccounts(): boolean { + return this._accounts != null && Object.values(this._accounts).some((a) => a.isAuthenticated); + } + protected get checkForUpdates(): MenuItemConstructorOptions { return { id: "checkForUpdates", diff --git a/apps/desktop/src/platform/main/biometric/biometrics.service.ts b/apps/desktop/src/platform/main/biometric/biometrics.service.ts index e432939c877..a8b6566c430 100644 --- a/apps/desktop/src/platform/main/biometric/biometrics.service.ts +++ b/apps/desktop/src/platform/main/biometric/biometrics.service.ts @@ -170,8 +170,12 @@ export class BiometricsService extends DesktopBiometricsService { try { response = await callback(); restartReload ||= restartReloadCallback(response); - } catch { - restartReload = true; + } catch (error) { + if (error.message === "Biometric authentication failed") { + restartReload = false; + } else { + restartReload = true; + } } if (restartReload) { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index ef6cbd2804a..1314670c44c 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -188,7 +188,7 @@ export class AppComponent implements OnDestroy, OnInit { if (premiumConfirmed) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["settings/subscription/premium"]); + await this.router.navigate(["settings/subscription/premium"]); } break; } diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index 8420916c8e0..120b570cb1e 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -21,8 +21,8 @@ >{{ "upgradeDiscount" | i18n - : (selectedInterval === planIntervals.Annually - ? discountPercentageFromSub + this.discountPercentage + : (selectedInterval === planIntervals.Annually && discountPercentageFromSub == 0 + ? this.discountPercentage : this.discountPercentageFromSub) }} @@ -318,7 +318,7 @@ type="info" title="SECRETS MANAGER SUBSCRIPTION" > - {{ "secretsManagerSubInfo" | i18n }} + {{ "secretsManagerSubscriptionInfo" | i18n }}

{{ "total" | i18n }}: {{ total | currency: "USD" : "$" }} USD{{ "total" | i18n }}: + {{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }} USD / {{ selectedPlanInterval | i18n }} + + + + + + + + diff --git a/apps/web/src/app/vault/individual-vault/add-edit-v2.component.spec.ts b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.spec.ts new file mode 100644 index 00000000000..cd3fec73778 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.spec.ts @@ -0,0 +1,124 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute, Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { DialogService } from "@bitwarden/components"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; +import { CipherFormConfig, DefaultCipherFormConfigService } from "@bitwarden/vault"; + +import { AddEditComponentV2 } from "./add-edit-v2.component"; + +describe("AddEditComponentV2", () => { + let component: AddEditComponentV2; + let fixture: ComponentFixture; + let organizationService: MockProxy; + let policyService: MockProxy; + let billingAccountProfileStateService: MockProxy; + let activatedRoute: MockProxy; + let dialogRef: MockProxy>; + let dialogService: MockProxy; + let cipherService: MockProxy; + let messagingService: MockProxy; + let folderService: MockProxy; + let collectionService: MockProxy; + + const mockParams = { + cloneMode: false, + cipherFormConfig: mock(), + }; + + beforeEach(async () => { + const mockOrganization: Organization = { + id: "org-id", + name: "Test Organization", + } as Organization; + + organizationService = mock(); + organizationService.organizations$ = of([mockOrganization]); + + policyService = mock(); + policyService.policyAppliesToActiveUser$.mockImplementation((policyType: PolicyType) => + of(true), + ); + + billingAccountProfileStateService = mock(); + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); + + activatedRoute = mock(); + activatedRoute.queryParams = of({}); + + dialogRef = mock>(); + dialogService = mock(); + messagingService = mock(); + folderService = mock(); + folderService.folderViews$ = of([]); + collectionService = mock(); + collectionService.decryptedCollections$ = of([]); + + const mockDefaultCipherFormConfigService = { + buildConfig: jest.fn().mockResolvedValue({ + allowPersonal: true, + allowOrganization: true, + }), + }; + + await TestBed.configureTestingModule({ + imports: [AddEditComponentV2], + providers: [ + { provide: DIALOG_DATA, useValue: mockParams }, + { provide: DialogRef, useValue: dialogRef }, + { provide: I18nService, useValue: { t: jest.fn().mockReturnValue("login") } }, + { provide: DialogService, useValue: dialogService }, + { provide: CipherService, useValue: cipherService }, + { provide: MessagingService, useValue: messagingService }, + { provide: OrganizationService, useValue: organizationService }, + { provide: Router, useValue: mock() }, + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: CollectionService, useValue: collectionService }, + { provide: FolderService, useValue: folderService }, + { provide: CryptoService, useValue: mock() }, + { provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService }, + { provide: PolicyService, useValue: policyService }, + { provide: DefaultCipherFormConfigService, useValue: mockDefaultCipherFormConfigService }, + { + provide: PasswordGenerationServiceAbstraction, + useValue: mock(), + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AddEditComponentV2); + component = fixture.componentInstance; + }); + + describe("ngOnInit", () => { + it("initializes the component with cipher", async () => { + await component.ngOnInit(); + + expect(component).toBeTruthy(); + }); + }); + + describe("cancel", () => { + it("handles cancel action", async () => { + const spyClose = jest.spyOn(dialogRef, "close"); + + await component.cancel(); + + expect(spyClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts new file mode 100644 index 00000000000..64935c8af38 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts @@ -0,0 +1,177 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { AsyncActionsModule, DialogModule, DialogService, ItemModule } from "@bitwarden/components"; +import { + CipherAttachmentsComponent, + CipherFormConfig, + CipherFormGenerationService, + CipherFormMode, + CipherFormModule, +} from "@bitwarden/vault"; + +import { WebCipherFormGenerationService } from "../../../../../../libs/vault/src/cipher-form/services/web-cipher-form-generation.service"; +import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component"; +import { SharedModule } from "../../shared/shared.module"; + +import { AttachmentsV2Component } from "./attachments-v2.component"; + +/** + * The result of the AddEditCipherDialogV2 component. + */ +export enum AddEditCipherDialogResult { + Edited = "edited", + Added = "added", + Canceled = "canceled", +} + +/** + * The close result of the AddEditCipherDialogV2 component. + */ +export interface AddEditCipherDialogCloseResult { + /** + * The action that was taken. + */ + action: AddEditCipherDialogResult; + /** + * The ID of the cipher that was edited or added. + */ + id?: CipherId; +} + +/** + * Component for viewing a cipher, presented in a dialog. + */ +@Component({ + selector: "app-vault-add-edit-v2", + templateUrl: "add-edit-v2.component.html", + standalone: true, + imports: [ + CipherViewComponent, + CommonModule, + AsyncActionsModule, + DialogModule, + SharedModule, + CipherFormModule, + CipherAttachmentsComponent, + ItemModule, + ], + providers: [{ provide: CipherFormGenerationService, useClass: WebCipherFormGenerationService }], +}) +export class AddEditComponentV2 implements OnInit { + config: CipherFormConfig; + headerText: string; + canAccessAttachments: boolean = false; + + /** + * Constructor for the AddEditComponentV2 component. + * @param params The parameters for the component. + * @param dialogRef The reference to the dialog. + * @param i18nService The internationalization service. + * @param dialogService The dialog service. + * @param billingAccountProfileStateService The billing account profile state service. + */ + constructor( + @Inject(DIALOG_DATA) public params: CipherFormConfig, + private dialogRef: DialogRef, + private i18nService: I18nService, + private dialogService: DialogService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + ) { + this.billingAccountProfileStateService.hasPremiumFromAnySource$ + .pipe(takeUntilDestroyed()) + .subscribe((canAccessPremium) => { + this.canAccessAttachments = canAccessPremium; + }); + } + + /** + * Lifecycle hook for component initialization. + */ + async ngOnInit() { + this.config = this.params; + this.headerText = this.setHeader(this.config?.mode, this.config.cipherType); + } + + /** + * Getter to check if the component is loading. + */ + get loading() { + return this.config == null; + } + + /** + * Method to handle cancel action. Called when a user clicks the cancel button. + */ + async cancel() { + this.dialogRef.close({ action: AddEditCipherDialogResult.Canceled }); + } + + /** + * Sets the header text based on the mode and type of the cipher. + * @param mode The form mode. + * @param type The cipher type. + * @returns The header text. + */ + setHeader(mode: CipherFormMode, type: CipherType) { + const partOne = mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader"; + switch (type) { + case CipherType.Login: + return this.i18nService.t(partOne, this.i18nService.t("typeLogin").toLowerCase()); + case CipherType.Card: + return this.i18nService.t(partOne, this.i18nService.t("typeCard").toLowerCase()); + case CipherType.Identity: + return this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLowerCase()); + case CipherType.SecureNote: + return this.i18nService.t(partOne, this.i18nService.t("note").toLowerCase()); + } + } + + /** + * Opens the attachments dialog. + */ + async openAttachmentsDialog() { + this.dialogService.open( + AttachmentsV2Component, + { + data: { + cipherId: this.config.originalCipher?.id as CipherId, + }, + }, + ); + } + + /** + * Handles the event when a cipher is saved. + * @param cipherView The cipher view that was saved. + */ + async onCipherSaved(cipherView: CipherView) { + this.dialogRef.close({ + action: + this.config.mode === "add" + ? AddEditCipherDialogResult.Added + : AddEditCipherDialogResult.Edited, + id: cipherView.id as CipherId, + }); + } +} + +/** + * Strongly typed helper to open a cipher add/edit dialog + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + * @returns A reference to the opened dialog + */ +export function openAddEditCipherDialog( + dialogService: DialogService, + config: DialogConfig, +): DialogRef { + return dialogService.open(AddEditComponentV2, config); +} diff --git a/apps/web/src/app/vault/individual-vault/attachments-v2.component.html b/apps/web/src/app/vault/individual-vault/attachments-v2.component.html new file mode 100644 index 00000000000..532a0224be4 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/attachments-v2.component.html @@ -0,0 +1,19 @@ + + + {{ "attachments" | i18n }} + + + + + + + + diff --git a/apps/web/src/app/vault/individual-vault/attachments-v2.component.spec.ts b/apps/web/src/app/vault/individual-vault/attachments-v2.component.spec.ts new file mode 100644 index 00000000000..8099d8fe929 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/attachments-v2.component.spec.ts @@ -0,0 +1,65 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; + +import { + AttachmentsV2Component, + AttachmentDialogResult, + AttachmentsDialogParams, +} from "./attachments-v2.component"; + +describe("AttachmentsV2Component", () => { + let component: AttachmentsV2Component; + let fixture: ComponentFixture; + + const mockCipherId: CipherId = "cipher-id" as CipherId; + const mockParams: AttachmentsDialogParams = { + cipherId: mockCipherId, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AttachmentsV2Component, NoopAnimationsModule], + providers: [ + { provide: DIALOG_DATA, useValue: mockParams }, + { provide: DialogRef, useValue: mock() }, + { provide: I18nService, useValue: mock() }, + { provide: CipherService, useValue: mock() }, + { provide: LogService, useValue: mock() }, + { provide: AccountService, useValue: mock() }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AttachmentsV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("initializes without errors and with the correct cipherId", () => { + expect(component).toBeTruthy(); + expect(component.cipherId).toBe(mockParams.cipherId); + }); + + it("closes the dialog with 'uploaded' result on uploadSuccessful", () => { + const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close"); + + component.uploadSuccessful(); + + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Uploaded }); + }); + + it("closes the dialog with 'removed' result on removalSuccessful", () => { + const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close"); + + component.removalSuccessful(); + + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed }); + }); +}); diff --git a/apps/web/src/app/vault/individual-vault/attachments-v2.component.ts b/apps/web/src/app/vault/individual-vault/attachments-v2.component.ts new file mode 100644 index 00000000000..e3b974e6c09 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/attachments-v2.component.ts @@ -0,0 +1,87 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; + +import { CipherId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; +import { CipherAttachmentsComponent } from "@bitwarden/vault"; + +import { SharedModule } from "../../shared"; + +export interface AttachmentsDialogParams { + cipherId: CipherId; +} + +/** + * Enum representing the possible results of the attachment dialog. + */ +export enum AttachmentDialogResult { + Uploaded = "uploaded", + Removed = "removed", + Closed = "closed", +} + +export interface AttachmentDialogCloseResult { + action: AttachmentDialogResult; +} + +/** + * Component for the attachments dialog. + */ +@Component({ + selector: "app-vault-attachments-v2", + templateUrl: "attachments-v2.component.html", + standalone: true, + imports: [CommonModule, SharedModule, CipherAttachmentsComponent], +}) +export class AttachmentsV2Component { + cipherId: CipherId; + attachmentFormId = CipherAttachmentsComponent.attachmentFormID; + + /** + * Constructor for AttachmentsV2Component. + * @param dialogRef - Reference to the dialog. + * @param params - Parameters passed to the dialog. + */ + constructor( + private dialogRef: DialogRef, + @Inject(DIALOG_DATA) public params: AttachmentsDialogParams, + ) { + this.cipherId = params.cipherId; + } + + /** + * Opens the attachments dialog. + * @param dialogService - The dialog service. + * @param params - The parameters for the dialog. + * @returns The dialog reference. + */ + static open( + dialogService: DialogService, + params: AttachmentsDialogParams, + ): DialogRef { + return dialogService.open(AttachmentsV2Component, { + data: params, + }); + } + + /** + * Called when an attachment is successfully uploaded. + * Closes the dialog with an 'uploaded' result. + */ + uploadSuccessful() { + this.dialogRef.close({ + action: AttachmentDialogResult.Uploaded, + }); + } + + /** + * Called when an attachment is successfully removed. + * Closes the dialog with a 'removed' result. + */ + removalSuccessful() { + this.dialogRef.close({ + action: AttachmentDialogResult.Removed, + }); + } +} diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index dcf62235d1b..a288b298bad 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -47,20 +47,25 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherId, OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { DialogService, Icons, ToastService } from "@bitwarden/components"; -import { CollectionAssignmentResult, PasswordRepromptService } from "@bitwarden/vault"; +import { + CollectionAssignmentResult, + DefaultCipherFormConfigService, + PasswordRepromptService, +} from "@bitwarden/vault"; import { SharedModule } from "../../shared/shared.module"; import { AssignCollectionsWebComponent } from "../components/assign-collections"; @@ -74,7 +79,17 @@ import { VaultItemEvent } from "../components/vault-items/vault-item-event"; import { VaultItemsModule } from "../components/vault-items/vault-items.module"; import { getNestedCollectionTree } from "../utils/collection-utils"; +import { + AddEditCipherDialogCloseResult, + AddEditCipherDialogResult, + openAddEditCipherDialog, +} from "./add-edit-v2.component"; import { AddEditComponent } from "./add-edit.component"; +import { + AttachmentDialogCloseResult, + AttachmentDialogResult, + AttachmentsV2Component, +} from "./attachments-v2.component"; import { AttachmentsComponent } from "./attachments.component"; import { BulkDeleteDialogResult, @@ -131,7 +146,11 @@ const SearchTextDebounceInterval = 200; VaultItemsModule, SharedModule, ], - providers: [RoutedVaultFilterService, RoutedVaultFilterBridgeService], + providers: [ + RoutedVaultFilterService, + RoutedVaultFilterBridgeService, + DefaultCipherFormConfigService, + ], }) export class VaultComponent implements OnInit, OnDestroy { @ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent; @@ -170,6 +189,7 @@ export class VaultComponent implements OnInit, OnDestroy { private searchText$ = new Subject(); private refresh$ = new BehaviorSubject(null); private destroy$ = new Subject(); + private extensionRefreshEnabled: boolean; constructor( private syncService: SyncService, @@ -200,6 +220,7 @@ export class VaultComponent implements OnInit, OnDestroy { private billingAccountProfileStateService: BillingAccountProfileStateService, private toastService: ToastService, private accountService: AccountService, + private cipherFormConfigService: DefaultCipherFormConfigService, ) {} async ngOnInit() { @@ -416,6 +437,11 @@ export class VaultComponent implements OnInit, OnDestroy { this.refreshing = false; }, ); + + // Check if the extension refresh feature flag is enabled + this.extensionRefreshEnabled = await this.configService.getFeatureFlag( + FeatureFlag.ExtensionRefresh, + ); } ngOnDestroy() { @@ -511,6 +537,15 @@ export class VaultComponent implements OnInit, OnDestroy { this.searchText$.next(searchText); } + /** + * Handles opening the attachments dialog for a cipher. + * Runs several checks to ensure that the user has the correct permissions + * and then opens the attachments dialog. + * Uses the new AttachmentsV2Component if the extensionRefresh feature flag is enabled. + * + * @param cipher + * @returns + */ async editCipherAttachments(cipher: CipherView) { if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { this.go({ cipherId: null, itemId: null }); @@ -536,6 +571,24 @@ export class VaultComponent implements OnInit, OnDestroy { ); let madeAttachmentChanges = false; + + if (this.extensionRefreshEnabled) { + const dialogRef = AttachmentsV2Component.open(this.dialogService, { + cipherId: cipher.id as CipherId, + }); + + const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed); + + if ( + result.action === AttachmentDialogResult.Uploaded || + result.action === AttachmentDialogResult.Removed + ) { + this.refresh(); + } + + return; + } + const [modal] = await this.modalService.openViewRef( AttachmentsComponent, this.attachmentsModalRef, @@ -598,7 +651,11 @@ export class VaultComponent implements OnInit, OnDestroy { } async addCipher(cipherType?: CipherType) { - const component = await this.editCipher(null); + if (this.extensionRefreshEnabled) { + return this.addCipherV2(cipherType); + } + + const component = (await this.editCipher(null)) as AddEditComponent; component.type = cipherType || this.activeFilter.cipherType; if ( this.activeFilter.organizationId !== "MyVault" && @@ -622,18 +679,60 @@ export class VaultComponent implements OnInit, OnDestroy { component.folderId = this.activeFilter.folderId; } + /** + * Opens the add cipher dialog. + * @param cipherType The type of cipher to add. + * @returns The dialog reference. + */ + async addCipherV2(cipherType?: CipherType) { + const cipherFormConfig = await this.cipherFormConfigService.buildConfig( + "add", + null, + cipherType, + ); + cipherFormConfig.initialValues = { + organizationId: + this.activeFilter.organizationId !== "MyVault" && this.activeFilter.organizationId != null + ? (this.activeFilter.organizationId as OrganizationId) + : null, + collectionIds: + this.activeFilter.collectionId !== "AllCollections" && + this.activeFilter.collectionId != null + ? [this.activeFilter.collectionId as CollectionId] + : [], + folderId: this.activeFilter.folderId, + }; + + // Open the dialog. + const dialogRef = openAddEditCipherDialog(this.dialogService, { + data: cipherFormConfig, + }); + + // Wait for the dialog to close. + const result: AddEditCipherDialogCloseResult = await lastValueFrom(dialogRef.closed); + + // Refresh the vault to show the new cipher. + if (result?.action === AddEditCipherDialogResult.Added) { + this.refresh(); + this.go({ itemId: result.id, action: "view" }); + return; + } + + // If the dialog was closed by any other action navigate back to the vault. + this.go({ cipherId: null, itemId: null, action: null }); + } + async navigateToCipher(cipher: CipherView) { this.go({ itemId: cipher?.id }); } - async editCipher(cipher: CipherView) { - return this.editCipherId(cipher?.id); + async editCipher(cipher: CipherView, cloneMode?: boolean) { + return this.editCipherId(cipher?.id, cloneMode); } - async editCipherId(id: string) { + async editCipherId(id: string, cloneMode?: boolean) { const cipher = await this.cipherService.get(id); - // if cipher exists (cipher is null when new) and MP reprompt - // is on for this cipher, then show password reprompt + if ( cipher && cipher.reprompt !== 0 && @@ -644,6 +743,11 @@ export class VaultComponent implements OnInit, OnDestroy { return; } + if (this.extensionRefreshEnabled) { + await this.editCipherIdV2(cipher, cloneMode); + return; + } + const [modal, childComponent] = await this.modalService.openViewRef( AddEditComponent, this.cipherAddEditModalRef, @@ -673,6 +777,46 @@ export class VaultComponent implements OnInit, OnDestroy { return childComponent; } + /** + * Edit a cipher using the new AddEditCipherDialogV2 component. + * + * @param cipher + * @param cloneMode + */ + private async editCipherIdV2(cipher: Cipher, cloneMode?: boolean) { + const cipherFormConfig = await this.cipherFormConfigService.buildConfig( + cloneMode ? "clone" : "edit", + cipher.id as CipherId, + cipher.type, + ); + + const dialogRef = openAddEditCipherDialog(this.dialogService, { + data: cipherFormConfig, + }); + + const result: AddEditCipherDialogCloseResult = await firstValueFrom(dialogRef.closed); + + /** + * Refresh the vault if the dialog was closed by adding, editing, or deleting a cipher. + */ + if (result?.action === AddEditCipherDialogResult.Edited) { + this.refresh(); + } + + /** + * View the cipher if the dialog was closed by editing the cipher. + */ + if (result?.action === AddEditCipherDialogResult.Edited) { + this.go({ itemId: cipher.id, action: "view" }); + return; + } + + /** + * Navigate to the vault if the dialog was closed by any other action. + */ + this.go({ cipherId: null, itemId: null, action: null }); + } + /** * Takes a CipherView and opens a dialog where it can be viewed (wraps viewCipherById). * @param cipher - CipherView @@ -718,8 +862,9 @@ export class VaultComponent implements OnInit, OnDestroy { const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed); // If the dialog was closed by deleting the cipher, refresh the vault. - if (result?.action === ViewCipherDialogResult.deleted) { + if (result?.action === ViewCipherDialogResult.Deleted) { this.refresh(); + this.go({ cipherId: null, itemId: null, action: null }); } // If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault. @@ -873,7 +1018,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - const component = await this.editCipher(cipher); + const component = await this.editCipher(cipher, true); component.cloneMode = true; } diff --git a/apps/web/src/app/vault/individual-vault/view.component.spec.ts b/apps/web/src/app/vault/individual-vault/view.component.spec.ts index fec97e202ef..dfa2d50489b 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.spec.ts @@ -98,7 +98,7 @@ describe("ViewComponent", () => { organizationId: mockCipher.organizationId, }, }); - expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.edited }); + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.Edited }); }); }); @@ -111,7 +111,7 @@ describe("ViewComponent", () => { await component.delete(); expect(deleteSpy).toHaveBeenCalled(); - expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.deleted }); + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.Deleted }); }); }); }); diff --git a/apps/web/src/app/vault/individual-vault/view.component.ts b/apps/web/src/app/vault/individual-vault/view.component.ts index fe317490c1f..fe846c9f64c 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -1,6 +1,6 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, Inject, OnDestroy, OnInit } from "@angular/core"; +import { Component, Inject, OnInit, EventEmitter, OnDestroy } from "@angular/core"; import { Router } from "@angular/router"; import { Subject } from "rxjs"; @@ -19,16 +19,19 @@ import { ToastService, } from "@bitwarden/components"; +import { PremiumUpgradePromptService } from "../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service"; import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component"; import { SharedModule } from "../../shared/shared.module"; +import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service"; export interface ViewCipherDialogParams { cipher: CipherView; } export enum ViewCipherDialogResult { - edited = "edited", - deleted = "deleted", + Edited = "edited", + Deleted = "deleted", + PremiumUpgrade = "premiumUpgrade", } export interface ViewCipherDialogCloseResult { @@ -43,6 +46,9 @@ export interface ViewCipherDialogCloseResult { templateUrl: "view.component.html", standalone: true, imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule], + providers: [ + { provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService }, + ], }) export class ViewComponent implements OnInit, OnDestroy { cipher: CipherView; @@ -117,7 +123,7 @@ export class ViewComponent implements OnInit, OnDestroy { this.logService.error(e); } - this.dialogRef.close({ action: ViewCipherDialogResult.deleted }); + this.dialogRef.close({ action: ViewCipherDialogResult.Deleted }); await this.router.navigate(["/vault"]); }; @@ -137,7 +143,7 @@ export class ViewComponent implements OnInit, OnDestroy { * Method to handle cipher editing. Called when a user clicks the edit button. */ async edit(): Promise { - this.dialogRef.close({ action: ViewCipherDialogResult.edited }); + this.dialogRef.close({ action: ViewCipherDialogResult.Edited }); await this.router.navigate([], { queryParams: { itemId: this.cipher.id, diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index add1ecbe3e5..a2d8c29cbac 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -886,12 +886,13 @@ export class VaultComponent implements OnInit, OnDestroy { const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed); // If the dialog was closed by deleting the cipher, refresh the vault. - if (result.action === ViewCipherDialogResult.deleted) { + if (result?.action === ViewCipherDialogResult.Deleted) { this.refresh(); + this.go({ cipherId: null, itemId: null, action: null }); } // If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault. - if (!result.action) { + if (!result?.action) { this.go({ cipherId: null, itemId: null, action: null }); } } diff --git a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts new file mode 100644 index 00000000000..6c68dae7077 --- /dev/null +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts @@ -0,0 +1,95 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { of, lastValueFrom } from "rxjs"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; + +import { + ViewCipherDialogCloseResult, + ViewCipherDialogResult, +} from "../individual-vault/view.component"; + +import { WebVaultPremiumUpgradePromptService } from "./web-premium-upgrade-prompt.service"; + +describe("WebVaultPremiumUpgradePromptService", () => { + let service: WebVaultPremiumUpgradePromptService; + let dialogServiceMock: jest.Mocked; + let routerMock: jest.Mocked; + let dialogRefMock: jest.Mocked>; + + beforeEach(() => { + dialogServiceMock = { + openSimpleDialog: jest.fn(), + } as unknown as jest.Mocked; + + routerMock = { + navigate: jest.fn(), + } as unknown as jest.Mocked; + + dialogRefMock = { + close: jest.fn(), + } as unknown as jest.Mocked>; + + TestBed.configureTestingModule({ + providers: [ + WebVaultPremiumUpgradePromptService, + { provide: DialogService, useValue: dialogServiceMock }, + { provide: Router, useValue: routerMock }, + { provide: DialogRef, useValue: dialogRefMock }, + ], + }); + + service = TestBed.inject(WebVaultPremiumUpgradePromptService); + }); + + it("prompts for premium upgrade and navigates to organization billing if organizationId is provided", async () => { + dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true))); + const organizationId = "test-org-id" as OrganizationId; + + await service.promptForPremium(organizationId); + + expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "upgradeOrganization" }, + content: { key: "upgradeOrganizationDesc" }, + acceptButtonText: { key: "upgradeOrganization" }, + type: "info", + }); + expect(routerMock.navigate).toHaveBeenCalledWith([ + "organizations", + organizationId, + "billing", + "subscription", + ]); + expect(dialogRefMock.close).toHaveBeenCalledWith({ + action: ViewCipherDialogResult.PremiumUpgrade, + }); + }); + + it("prompts for premium upgrade and navigates to premium subscription if organizationId is not provided", async () => { + dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true))); + + await service.promptForPremium(); + + expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "premiumRequired" }, + content: { key: "premiumRequiredDesc" }, + acceptButtonText: { key: "upgrade" }, + type: "success", + }); + expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]); + expect(dialogRefMock.close).toHaveBeenCalledWith({ + action: ViewCipherDialogResult.PremiumUpgrade, + }); + }); + + it("does not navigate or close dialog if upgrade is no action is taken", async () => { + dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(false))); + + await service.promptForPremium("test-org-id" as OrganizationId); + + expect(routerMock.navigate).not.toHaveBeenCalled(); + expect(dialogRefMock.close).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts new file mode 100644 index 00000000000..8f9c8c0bd72 --- /dev/null +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts @@ -0,0 +1,57 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { Injectable } from "@angular/core"; +import { Router } from "@angular/router"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { DialogService } from "@bitwarden/components"; + +import { + ViewCipherDialogCloseResult, + ViewCipherDialogResult, +} from "../individual-vault/view.component"; + +/** + * This service is used to prompt the user to upgrade to premium. + */ +@Injectable() +export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePromptService { + constructor( + private dialogService: DialogService, + private router: Router, + private dialog: DialogRef, + ) {} + + /** + * Prompts the user to upgrade to premium. + * @param organizationId The ID of the organization to upgrade. + */ + async promptForPremium(organizationId?: OrganizationId) { + let upgradeConfirmed; + if (organizationId) { + upgradeConfirmed = await this.dialogService.openSimpleDialog({ + title: { key: "upgradeOrganization" }, + content: { key: "upgradeOrganizationDesc" }, + acceptButtonText: { key: "upgradeOrganization" }, + type: "info", + }); + if (upgradeConfirmed) { + await this.router.navigate(["organizations", organizationId, "billing", "subscription"]); + } + } else { + upgradeConfirmed = await this.dialogService.openSimpleDialog({ + title: { key: "premiumRequired" }, + content: { key: "premiumRequiredDesc" }, + acceptButtonText: { key: "upgrade" }, + type: "success", + }); + if (upgradeConfirmed) { + await this.router.navigate(["settings/subscription/premium"]); + } + } + + if (upgradeConfirmed) { + this.dialog.close({ action: ViewCipherDialogResult.PremiumUpgrade }); + } + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index cdfae904236..87c0ad654fd 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -511,6 +511,24 @@ "viewItem": { "message": "View item" }, + "newItemHeader": { + "message": "New $TYPE$", + "placeholders": { + "type": { + "content": "$1", + "example": "login" + } + } + }, + "editItemHeader": { + "message": "Edit $TYPE$", + "placeholders": { + "type": { + "content": "$1", + "example": "login" + } + } + }, "viewItemType": { "message": "View $ITEMTYPE$", "placeholders": { @@ -7399,6 +7417,9 @@ "fileUpload": { "message": "File upload" }, + "upload": { + "message": "Upload" + }, "acceptedFormats": { "message": "Accepted Formats:" }, @@ -9131,8 +9152,8 @@ "current": { "message": "Current" }, - "secretsManagerSubInfo": { - "message": "Your Secrets Manager subscription will upgrade base on the plan selected" + "secretsManagerSubscriptionInfo": { + "message": "Your Secrets Manager subscription will upgrade based on the plan selected" }, "bitwardenPasswordManager": { "message": "Bitwarden Password Manager" diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index cec4bf044be..99dc7479257 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -135,6 +135,8 @@ const plugins = [ template: "./src/404.html", filename: "404.html", chunks: ["styles"], + // 404 page is a wildcard, this ensures it uses absolute paths. + publicPath: "/", }), new CopyWebpackPlugin({ patterns: [ diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts index 289e94e6eff..bd9bb6e5f6f 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts @@ -1,7 +1,7 @@ import { TextEncoder } from "util"; import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; import { UserId } from "../../../types/guid"; @@ -53,7 +53,9 @@ describe("FidoAuthenticatorService", () => { userInterface = mock(); userInterfaceSession = mock(); userInterface.newSession.mockResolvedValue(userInterfaceSession); - syncService = mock(); + syncService = mock({ + activeUserLastSync$: () => of(new Date()), + }); accountService = mock(); authenticator = new Fido2AuthenticatorService( cipherService, diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts index ddcc079eb95..8f0523769d9 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts @@ -94,7 +94,14 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr } await userInterfaceSession.ensureUnlockedVault(); - await this.syncService.fullSync(false); + + // Avoid syncing if we did it reasonably soon as the only reason for syncing is to validate excludeCredentials + const lastSync = await firstValueFrom(this.syncService.activeUserLastSync$()); + const threshold = new Date().getTime() - 1000 * 60 * 30; // 30 minutes ago + + if (!lastSync || lastSync.getTime() < threshold) { + await this.syncService.fullSync(false); + } const existingCipherIds = await this.findExcludedCredentials( params.excludeCredentialDescriptorList, @@ -223,15 +230,17 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr let cipherOptions: CipherView[]; await userInterfaceSession.ensureUnlockedVault(); - await this.syncService.fullSync(false); - if (params.allowCredentialDescriptorList?.length > 0) { - cipherOptions = await this.findCredentialsById( - params.allowCredentialDescriptorList, - params.rpId, - ); - } else { - cipherOptions = await this.findCredentialsByRp(params.rpId); + // Try to find the passkey locally before causing a sync to speed things up + // only skip syncing if we found credentials AND all of them have a counter = 0 + cipherOptions = await this.findCredential(params, cipherOptions); + if ( + cipherOptions.length === 0 || + cipherOptions.some((c) => c.login.fido2Credentials.some((p) => p.counter > 0)) + ) { + // If no passkey is found, or any had a non-zero counter, sync to get the latest data + await this.syncService.fullSync(false); + cipherOptions = await this.findCredential(params, cipherOptions); } if (cipherOptions.length === 0) { @@ -335,6 +344,21 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr } } + private async findCredential( + params: Fido2AuthenticatorGetAssertionParams, + cipherOptions: CipherView[], + ) { + if (params.allowCredentialDescriptorList?.length > 0) { + cipherOptions = await this.findCredentialsById( + params.allowCredentialDescriptorList, + params.rpId, + ); + } else { + cipherOptions = await this.findCredentialsByRp(params.rpId); + } + return cipherOptions; + } + private requiresUserVerificationPrompt( params: Fido2AuthenticatorGetAssertionParams, cipherOptions: CipherView[], diff --git a/libs/common/src/vault/abstractions/premium-upgrade-prompt.service.ts b/libs/common/src/vault/abstractions/premium-upgrade-prompt.service.ts new file mode 100644 index 00000000000..8733baaa471 --- /dev/null +++ b/libs/common/src/vault/abstractions/premium-upgrade-prompt.service.ts @@ -0,0 +1,7 @@ +/** + * This interface defines the a contract for a service that prompts the user to upgrade to premium. + * It ensures that PremiumUpgradePromptService contains a promptForPremium method. + */ +export abstract class PremiumUpgradePromptService { + abstract promptForPremium(organizationId?: string): Promise; +} diff --git a/libs/importer/spec/protonpass-json-importer.spec.ts b/libs/importer/spec/protonpass-json-importer.spec.ts index d5f4653c643..39a09127c27 100644 --- a/libs/importer/spec/protonpass-json-importer.spec.ts +++ b/libs/importer/spec/protonpass-json-importer.spec.ts @@ -85,7 +85,7 @@ describe("Protonpass Json Importer", () => { // "My Secure Note" is assigned to folder "Personal" expect(result.folderRelationships[1]).toEqual([1, 0]); // "Other vault login" is assigned to folder "Test" - expect(result.folderRelationships[3]).toEqual([3, 1]); + expect(result.folderRelationships[4]).toEqual([4, 1]); }); it("should create collections if part of an organization", async () => { @@ -102,7 +102,7 @@ describe("Protonpass Json Importer", () => { // "My Secure Note" is assigned to folder "Personal" expect(result.collectionRelationships[1]).toEqual([1, 0]); // "Other vault login" is assigned to folder "Test" - expect(result.collectionRelationships[3]).toEqual([3, 1]); + expect(result.collectionRelationships[4]).toEqual([4, 1]); }); it("should not add deleted items", async () => { @@ -114,7 +114,7 @@ describe("Protonpass Json Importer", () => { expect(cipher.name).not.toBe("My Deleted Note"); } - expect(ciphers.length).toBe(4); + expect(ciphers.length).toBe(5); }); it("should set favorites", async () => { @@ -126,4 +126,97 @@ describe("Protonpass Json Importer", () => { expect(ciphers[1].favorite).toBe(false); expect(ciphers[2].favorite).toBe(true); }); + + it("should skip unsupported items", async () => { + const testDataJson = JSON.stringify(testData); + const result = await importer.parse(testDataJson); + expect(result != null).toBe(true); + + const ciphers = result.ciphers; + expect(ciphers.length).toBe(5); + expect(ciphers[4].type).toEqual(CipherType.Login); + }); + + it("should parse identity data", async () => { + const testDataJson = JSON.stringify(testData); + const result = await importer.parse(testDataJson); + expect(result != null).toBe(true); + + result.ciphers.shift(); + result.ciphers.shift(); + result.ciphers.shift(); + + const cipher = result.ciphers.shift(); + expect(cipher.type).toEqual(CipherType.Identity); + expect(cipher.identity.firstName).toBe("Test"); + expect(cipher.identity.middleName).toBe("1"); + expect(cipher.identity.lastName).toBe("1"); + expect(cipher.identity.email).toBe("test@gmail.com"); + expect(cipher.identity.phone).toBe("7507951789"); + expect(cipher.identity.company).toBe("Bitwarden"); + expect(cipher.identity.ssn).toBe("98378264782"); + expect(cipher.identity.passportNumber).toBe("7173716378612"); + expect(cipher.identity.licenseNumber).toBe("21234"); + expect(cipher.identity.address1).toBe("Bitwarden"); + expect(cipher.identity.address2).toBe("23 Street"); + expect(cipher.identity.address3).toBe("12th Foor Test County"); + expect(cipher.identity.city).toBe("New York"); + expect(cipher.identity.state).toBe("Test"); + expect(cipher.identity.postalCode).toBe("4038456"); + expect(cipher.identity.country).toBe("US"); + + expect(cipher.fields.length).toEqual(13); + + expect(cipher.fields.at(0).name).toEqual("gender"); + expect(cipher.fields.at(0).value).toEqual("Male"); + expect(cipher.fields.at(0).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(1).name).toEqual("TestPersonal"); + expect(cipher.fields.at(1).value).toEqual("Personal"); + expect(cipher.fields.at(1).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(2).name).toEqual("TestAddress"); + expect(cipher.fields.at(2).value).toEqual("Address"); + expect(cipher.fields.at(2).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(3).name).toEqual("xHandle"); + expect(cipher.fields.at(3).value).toEqual("@twiter"); + expect(cipher.fields.at(3).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(4).name).toEqual("secondPhoneNumber"); + expect(cipher.fields.at(4).value).toEqual("243538978"); + expect(cipher.fields.at(4).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(5).name).toEqual("instagram"); + expect(cipher.fields.at(5).value).toEqual("@insta"); + expect(cipher.fields.at(5).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(6).name).toEqual("TestContact"); + expect(cipher.fields.at(6).value).toEqual("Contact"); + expect(cipher.fields.at(6).type).toEqual(FieldType.Hidden); + + expect(cipher.fields.at(7).name).toEqual("jobTitle"); + expect(cipher.fields.at(7).value).toEqual("Engineer"); + expect(cipher.fields.at(7).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(8).name).toEqual("workPhoneNumber"); + expect(cipher.fields.at(8).value).toEqual("78236476238746"); + expect(cipher.fields.at(8).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(9).name).toEqual("TestWork"); + expect(cipher.fields.at(9).value).toEqual("Work"); + expect(cipher.fields.at(9).type).toEqual(FieldType.Hidden); + + expect(cipher.fields.at(10).name).toEqual("TestSection"); + expect(cipher.fields.at(10).value).toEqual("Section"); + expect(cipher.fields.at(10).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(11).name).toEqual("TestSectionHidden"); + expect(cipher.fields.at(11).value).toEqual("SectionHidden"); + expect(cipher.fields.at(11).type).toEqual(FieldType.Hidden); + + expect(cipher.fields.at(12).name).toEqual("TestExtra"); + expect(cipher.fields.at(12).value).toEqual("Extra"); + expect(cipher.fields.at(12).type).toEqual(FieldType.Text); + }); }); diff --git a/libs/importer/spec/test-data/protonpass-json/protonpass.json.ts b/libs/importer/spec/test-data/protonpass-json/protonpass.json.ts index a508a03debc..367c2b37e14 100644 --- a/libs/importer/spec/test-data/protonpass-json/protonpass.json.ts +++ b/libs/importer/spec/test-data/protonpass-json/protonpass.json.ts @@ -138,6 +138,144 @@ export const testData: ProtonPassJsonFile = { modifyTime: 1689182908, pinned: false, }, + { + itemId: + "gliCOyyJOsoBf5QIijvCF4QsPij3q_MR4nCXZ2sXm7YCJCfHjrRD_p2XG9vLsaytErsQvMhcLISVS7q8-7SCkg==", + shareId: + "TpawpLbs1nuUlQUCtgKZgb3zgAvbrGrOaqOylKqVe_RLROEyUvMq8_ZEuGw73PGRUSr89iNtQ2NosuggP54nwA==", + data: { + metadata: { + name: "Identity", + note: "", + itemUuid: "c2e52768", + }, + extraFields: [ + { + fieldName: "TestExtra", + type: "text", + data: { + content: "Extra", + }, + }, + ], + type: "identity", + content: { + fullName: "Test 1", + email: "test@gmail.com", + phoneNumber: "7507951789", + firstName: "Test", + middleName: "1", + lastName: "Test", + birthdate: "", + gender: "Male", + extraPersonalDetails: [ + { + fieldName: "TestPersonal", + type: "text", + data: { + content: "Personal", + }, + }, + ], + organization: "Bitwarden", + streetAddress: "23 Street", + zipOrPostalCode: "4038456", + city: "New York", + stateOrProvince: "Test", + countryOrRegion: "US", + floor: "12th Foor", + county: "Test County", + extraAddressDetails: [ + { + fieldName: "TestAddress", + type: "text", + data: { + content: "Address", + }, + }, + ], + socialSecurityNumber: "98378264782", + passportNumber: "7173716378612", + licenseNumber: "21234", + website: "", + xHandle: "@twiter", + secondPhoneNumber: "243538978", + linkedin: "", + reddit: "", + facebook: "", + yahoo: "", + instagram: "@insta", + extraContactDetails: [ + { + fieldName: "TestContact", + type: "hidden", + data: { + content: "Contact", + }, + }, + ], + company: "Bitwarden", + jobTitle: "Engineer", + personalWebsite: "", + workPhoneNumber: "78236476238746", + workEmail: "", + extraWorkDetails: [ + { + fieldName: "TestWork", + type: "hidden", + data: { + content: "Work", + }, + }, + ], + extraSections: [ + { + sectionName: "TestSection", + sectionFields: [ + { + fieldName: "TestSection", + type: "text", + data: { + content: "Section", + }, + }, + { + fieldName: "TestSectionHidden", + type: "hidden", + data: { + content: "SectionHidden", + }, + }, + ], + }, + ], + }, + }, + state: 1, + aliasEmail: null, + contentFormatVersion: 6, + createTime: 1725707298, + modifyTime: 1725707298, + pinned: false, + }, + { + itemId: + "WTKLZtKfHIC3Gv7gRXUANifNjj0gN3P_52I4MznAzig9GSb_OgJ0qcZ8taOZyfsFTLOWBslXwI-HSMWXVmnKzQ==", + shareId: + "TpawpLbs1nuUlQUCtgKZgb3zgAvbrGrOaqOylKqVe_RLROEyUvMq8_ZEuGw73PGRUSr89iNtQ2NosuggP54nwA==", + data: { + metadata: { name: "Alias", note: "", itemUuid: "576f14fa" }, + extraFields: [], + type: "alias", + content: {}, + }, + state: 1, + aliasEmail: "alias.removing005@passinbox.com", + contentFormatVersion: 6, + createTime: 1725708208, + modifyTime: 1725708208, + pinned: false, + }, ], }, REDACTED_VAULT_ID_B: { diff --git a/libs/importer/src/importers/protonpass/protonpass-import-utils.spec.ts b/libs/importer/src/importers/protonpass/protonpass-import-utils.spec.ts new file mode 100644 index 00000000000..efa91eb98f4 --- /dev/null +++ b/libs/importer/src/importers/protonpass/protonpass-import-utils.spec.ts @@ -0,0 +1,66 @@ +import { processNames } from "./protonpass-import-utils"; + +describe("processNames", () => { + it("should use only fullName to map names if it contains at least three words, ignoring individual name fields", () => { + const result = processNames("Alice Beth Carter", "Kevin", "", ""); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "Beth", + mappedLastName: "Carter", + }); + }); + + it("should map extra words to the middle name if fullName contains more than three words", () => { + const result = processNames("Alice Beth Middle Carter", "", "", ""); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "Beth Middle", + mappedLastName: "Carter", + }); + }); + + it("should map names correctly even if fullName has words separated by more than one space", () => { + const result = processNames("Alice Carter", "", "", ""); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "", + mappedLastName: "Carter", + }); + }); + + it("should handle a single name in fullName and use middleName and lastName to populate rest of names", () => { + const result = processNames("Alice", "", "Beth", "Carter"); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "Beth", + mappedLastName: "Carter", + }); + }); + + it("should correctly map fullName when it only contains two words", () => { + const result = processNames("Alice Carter", "", "", ""); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "", + mappedLastName: "Carter", + }); + }); + + it("should map middle name from middleName if fullName only contains two words", () => { + const result = processNames("Alice Carter", "", "Beth", ""); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "Beth", + mappedLastName: "Carter", + }); + }); + + it("should fall back to firstName, middleName, and lastName if fullName is empty", () => { + const result = processNames("", "Alice", "Beth", "Carter"); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "Beth", + mappedLastName: "Carter", + }); + }); +}); diff --git a/libs/importer/src/importers/protonpass/protonpass-import-utils.ts b/libs/importer/src/importers/protonpass/protonpass-import-utils.ts new file mode 100644 index 00000000000..d8e0a096ab7 --- /dev/null +++ b/libs/importer/src/importers/protonpass/protonpass-import-utils.ts @@ -0,0 +1,21 @@ +export function processNames( + fullname: string | null, + firstname: string | null, + middlename: string | null, + lastname: string | null, +) { + let mappedFirstName = firstname; + let mappedMiddleName = middlename; + let mappedLastName = lastname; + + if (fullname) { + const parts = fullname.trim().split(/\s+/); + + // Assign parts to first, middle, and last name based on the number of parts + mappedFirstName = parts[0] || firstname; + mappedLastName = parts.length > 1 ? parts[parts.length - 1] : lastname; + mappedMiddleName = parts.length > 2 ? parts.slice(1, -1).join(" ") : middlename; + } + + return { mappedFirstName, mappedMiddleName, mappedLastName }; +} diff --git a/libs/importer/src/importers/protonpass/protonpass-json-importer.ts b/libs/importer/src/importers/protonpass/protonpass-json-importer.ts index b8f6bc170c2..94d21f8521c 100644 --- a/libs/importer/src/importers/protonpass/protonpass-json-importer.ts +++ b/libs/importer/src/importers/protonpass/protonpass-json-importer.ts @@ -1,24 +1,110 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { FieldType, SecureNoteType, CipherType } from "@bitwarden/common/vault/enums"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; import { ImportResult } from "../../models/import-result"; import { BaseImporter } from "../base-importer"; import { Importer } from "../importer"; +import { processNames } from "./protonpass-import-utils"; import { ProtonPassCreditCardItemContent, + ProtonPassIdentityItemContent, + ProtonPassIdentityItemExtraSection, + ProtonPassItemExtraField, ProtonPassItemState, ProtonPassJsonFile, ProtonPassLoginItemContent, } from "./types/protonpass-json-type"; export class ProtonPassJsonImporter extends BaseImporter implements Importer { + private mappedIdentityItemKeys = [ + "fullName", + "firstName", + "middleName", + "lastName", + "email", + "phoneNumber", + "company", + "socialSecurityNumber", + "passportNumber", + "licenseNumber", + "organization", + "streetAddress", + "floor", + "county", + "city", + "stateOrProvince", + "zipOrPostalCode", + "countryOrRegion", + ]; + + private identityItemExtraFieldsKeys = [ + "extraPersonalDetails", + "extraAddressDetails", + "extraContactDetails", + "extraWorkDetails", + "extraSections", + ]; + constructor(private i18nService: I18nService) { super(); } + private processIdentityItemUnmappedAndExtraFields( + cipher: CipherView, + identityItem: ProtonPassIdentityItemContent, + ) { + Object.keys(identityItem).forEach((key) => { + if ( + !this.mappedIdentityItemKeys.includes(key) && + !this.identityItemExtraFieldsKeys.includes(key) + ) { + this.processKvp( + cipher, + key, + identityItem[key as keyof ProtonPassIdentityItemContent] as string, + ); + return; + } + + if (this.identityItemExtraFieldsKeys.includes(key)) { + if (key !== "extraSections") { + const extraFields = identityItem[ + key as keyof ProtonPassIdentityItemContent + ] as ProtonPassItemExtraField[]; + + extraFields?.forEach((extraField) => { + this.processKvp( + cipher, + extraField.fieldName, + extraField.data.content, + extraField.type === "hidden" ? FieldType.Hidden : FieldType.Text, + ); + }); + } else { + const extraSections = identityItem[ + key as keyof ProtonPassIdentityItemContent + ] as ProtonPassIdentityItemExtraSection[]; + + extraSections?.forEach((extraSection) => { + extraSection.sectionFields?.forEach((extraField) => { + this.processKvp( + cipher, + extraField.fieldName, + extraField.data.content, + extraField.type === "hidden" ? FieldType.Hidden : FieldType.Text, + ); + }); + }); + } + } + }); + } + parse(data: string): Promise { const result = new ImportResult(); const results: ProtonPassJsonFile = JSON.parse(data); @@ -38,7 +124,6 @@ export class ProtonPassJsonImporter extends BaseImporter implements Importer { if (item.state == ProtonPassItemState.TRASHED) { continue; } - this.processFolder(result, vault.name); const cipher = this.initLoginCipher(); cipher.name = this.getValueOrDefault(item.data.metadata.name, "--"); @@ -96,8 +181,55 @@ export class ProtonPassJsonImporter extends BaseImporter implements Importer { break; } + case "identity": { + const identityContent = item.data.content as ProtonPassIdentityItemContent; + cipher.type = CipherType.Identity; + cipher.identity = new IdentityView(); + + const { mappedFirstName, mappedMiddleName, mappedLastName } = processNames( + this.getValueOrDefault(identityContent.fullName), + this.getValueOrDefault(identityContent.firstName), + this.getValueOrDefault(identityContent.middleName), + this.getValueOrDefault(identityContent.lastName), + ); + cipher.identity.firstName = mappedFirstName; + cipher.identity.middleName = mappedMiddleName; + cipher.identity.lastName = mappedLastName; + + cipher.identity.email = this.getValueOrDefault(identityContent.email); + cipher.identity.phone = this.getValueOrDefault(identityContent.phoneNumber); + cipher.identity.company = this.getValueOrDefault(identityContent.company); + cipher.identity.ssn = this.getValueOrDefault(identityContent.socialSecurityNumber); + cipher.identity.passportNumber = this.getValueOrDefault(identityContent.passportNumber); + cipher.identity.licenseNumber = this.getValueOrDefault(identityContent.licenseNumber); + + const address3 = + `${identityContent.floor ?? ""} ${identityContent.county ?? ""}`.trim(); + cipher.identity.address1 = this.getValueOrDefault(identityContent.organization); + cipher.identity.address2 = this.getValueOrDefault(identityContent.streetAddress); + cipher.identity.address3 = this.getValueOrDefault(address3); + + cipher.identity.city = this.getValueOrDefault(identityContent.city); + cipher.identity.state = this.getValueOrDefault(identityContent.stateOrProvince); + cipher.identity.postalCode = this.getValueOrDefault(identityContent.zipOrPostalCode); + cipher.identity.country = this.getValueOrDefault(identityContent.countryOrRegion); + this.processIdentityItemUnmappedAndExtraFields(cipher, identityContent); + + for (const extraField of item.data.extraFields) { + this.processKvp( + cipher, + extraField.fieldName, + extraField.data.content, + extraField.type === "hidden" ? FieldType.Hidden : FieldType.Text, + ); + } + break; + } + default: + continue; } + this.processFolder(result, vault.name); this.cleanupCipher(cipher); result.ciphers.push(cipher); } diff --git a/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts b/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts index eb3b4bba5ac..20fa314a314 100644 --- a/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts +++ b/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts @@ -36,8 +36,11 @@ export type ProtonPassItemData = { metadata: ProtonPassItemMetadata; extraFields: ProtonPassItemExtraField[]; platformSpecific?: any; - type: "login" | "alias" | "creditCard" | "note"; - content: ProtonPassLoginItemContent | ProtonPassCreditCardItemContent; + type: "login" | "alias" | "creditCard" | "note" | "identity"; + content: + | ProtonPassLoginItemContent + | ProtonPassCreditCardItemContent + | ProtonPassIdentityItemContent; }; export type ProtonPassItemMetadata = { @@ -74,3 +77,48 @@ export type ProtonPassCreditCardItemContent = { expirationDate?: string; pin?: string; }; + +export type ProtonPassIdentityItemExtraSection = { + sectionName?: string; + sectionFields?: ProtonPassItemExtraField[]; +}; + +export type ProtonPassIdentityItemContent = { + fullName?: string; + email?: string; + phoneNumber?: string; + firstName?: string; + middleName?: string; + lastName?: string; + birthdate?: string; + gender?: string; + extraPersonalDetails?: ProtonPassItemExtraField[]; + organization?: string; + streetAddress?: string; + zipOrPostalCode?: string; + city?: string; + stateOrProvince?: string; + countryOrRegion?: string; + floor?: string; + county?: string; + extraAddressDetails?: ProtonPassItemExtraField[]; + socialSecurityNumber?: string; + passportNumber?: string; + licenseNumber?: string; + website?: string; + xHandle?: string; + secondPhoneNumber?: string; + linkedin?: string; + reddit?: string; + facebook?: string; + yahoo?: string; + instagram?: string; + extraContactDetails?: ProtonPassItemExtraField[]; + company?: string; + jobTitle?: string; + personalWebsite?: string; + workPhoneNumber?: string; + workEmail?: string; + extraWorkDetails?: ProtonPassItemExtraField[]; + extraSections?: ProtonPassIdentityItemExtraSection[]; +}; diff --git a/libs/tools/generator/components/src/icons/no-credentials.icon.ts b/libs/tools/generator/components/src/icons/no-credentials.icon.ts index 63843faccc9..7bb289600b8 100644 --- a/libs/tools/generator/components/src/icons/no-credentials.icon.ts +++ b/libs/tools/generator/components/src/icons/no-credentials.icon.ts @@ -3,20 +3,20 @@ import { svgIcon } from "@bitwarden/components"; export const NoCredentialsIcon = svgIcon` - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/base-send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/base-send-details.component.ts index 282f6caffab..f3e2229dd2b 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/base-send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/base-send-details.component.ts @@ -62,6 +62,8 @@ export class BaseSendDetailsComponent implements OnInit { } as SendView); }); }); + + this.sendFormContainer.registerChildForm("sendDetailsForm", this.sendDetailsForm); } async ngOnInit() { diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index e1f3f4f0d9f..47e1fc6059a 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -16,6 +16,13 @@ [sendDetailsForm]="sendDetailsForm" > + + {{ "deletionDate" | i18n }} +

+
{{ "file" | i18n }}
+
{{ originalSendView.file.fileName }}
+
{{ originalSendView.file.sizeName }}
+
+ + {{ "fileToShare" | i18n }} + + + {{ fileName || ("noFileChosen" | i18n) }} + + + {{ "maxFileSize" | i18n }} + + + diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts new file mode 100644 index 00000000000..86c9fa96f1a --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts @@ -0,0 +1,92 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { + FormBuilder, + FormControl, + FormGroup, + Validators, + ReactiveFormsModule, + FormsModule, +} from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendFileView } from "@bitwarden/common/tools/send/models/view/send-file.view"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { ButtonModule, FormFieldModule, SectionComponent } from "@bitwarden/components"; + +import { SendFormConfig } from "../../abstractions/send-form-config.service"; +import { SendFormContainer } from "../../send-form-container"; + +import { BaseSendDetailsForm } from "./base-send-details.component"; + +type BaseSendFileDetailsForm = FormGroup<{ + file: FormControl; +}>; + +export type SendFileDetailsForm = BaseSendFileDetailsForm & BaseSendDetailsForm; + +@Component({ + selector: "tools-send-file-details", + templateUrl: "./send-file-details.component.html", + standalone: true, + imports: [ + ButtonModule, + CommonModule, + JslibModule, + ReactiveFormsModule, + FormFieldModule, + SectionComponent, + FormsModule, + ], +}) +export class SendFileDetailsComponent implements OnInit { + @Input() config: SendFormConfig; + @Input() originalSendView?: SendView; + @Input() sendDetailsForm: BaseSendDetailsForm; + + baseSendFileDetailsForm: BaseSendFileDetailsForm; + sendFileDetailsForm: SendFileDetailsForm; + + FileSendType = SendType.File; + fileName = ""; + + constructor( + private formBuilder: FormBuilder, + protected sendFormContainer: SendFormContainer, + ) { + this.baseSendFileDetailsForm = this.formBuilder.group({ + file: this.formBuilder.control(null, Validators.required), + }); + + this.sendFileDetailsForm = Object.assign(this.baseSendFileDetailsForm, this.sendDetailsForm); + + this.sendFormContainer.registerChildForm("sendFileDetailsForm", this.sendFileDetailsForm); + + this.sendFileDetailsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { + this.sendFormContainer.patchSend((send) => { + return Object.assign(send, { + file: value.file, + }); + }); + }); + } + + onFileSelected = (event: Event): void => { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) { + return; + } + this.fileName = file.name; + this.sendFormContainer.onFileSelected(file); + }; + + ngOnInit() { + if (this.originalSendView) { + this.sendFileDetailsForm.patchValue({ + file: this.originalSendView.file, + }); + } + } +} diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts index 2cb1907d921..b265b644df4 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts @@ -65,6 +65,7 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send private bitSubmit: BitSubmitDirective; private destroyRef = inject(DestroyRef); private _firstInitialized = false; + private file: File | null = null; /** * The form ID to use for the form. Used to connect it to a submit button. @@ -188,14 +189,17 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send private i18nService: I18nService, ) {} + onFileSelected(file: File): void { + this.file = file; + } + submit = async () => { if (this.sendForm.invalid) { this.sendForm.markAllAsTouched(); return; } - // TODO: Add file handling - await this.addEditFormService.saveSend(this.updatedSendView, null, this.config); + await this.addEditFormService.saveSend(this.updatedSendView, this.file, this.config); this.toastService.showToast({ variant: "success", diff --git a/libs/tools/send/send-ui/src/send-form/send-form-container.ts b/libs/tools/send/send-ui/src/send-form/send-form-container.ts index f3af1ecd816..21508d96727 100644 --- a/libs/tools/send/send-ui/src/send-form/send-form-container.ts +++ b/libs/tools/send/send-ui/src/send-form/send-form-container.ts @@ -2,6 +2,7 @@ import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendFormConfig } from "./abstractions/send-form-config.service"; import { SendDetailsComponent } from "./components/send-details/send-details.component"; +import { SendFileDetailsForm } from "./components/send-details/send-file-details.component"; import { SendTextDetailsForm } from "./components/send-details/send-text-details.component"; /** * The complete form for a send. Includes all the sub-forms from their respective section components. @@ -10,6 +11,7 @@ import { SendTextDetailsForm } from "./components/send-details/send-text-details export type SendForm = { sendDetailsForm?: SendDetailsComponent["sendDetailsForm"]; sendTextDetailsForm?: SendTextDetailsForm; + sendFileDetailsForm?: SendFileDetailsForm; }; /** @@ -37,5 +39,7 @@ export abstract class SendFormContainer { group: Exclude, ): void; + abstract onFileSelected(file: File): void; + abstract patchSend(updateFn: (current: SendView) => SendView): void; } diff --git a/libs/tools/send/send-ui/src/send-form/send-form.mdx b/libs/tools/send/send-ui/src/send-form/send-form.mdx deleted file mode 100644 index d1297ee90ca..00000000000 --- a/libs/tools/send/send-ui/src/send-form/send-form.mdx +++ /dev/null @@ -1,17 +0,0 @@ -import { Controls, Meta, Primary } from "@storybook/addon-docs"; - -import * as stories from "./send-form.stories"; - - - -# Send Form - -The send form is a re-usable form component that can be used to create, update, and clone sends. It -is configured via a `SendFormConfig` object that is passed to the component as a prop. The -`SendFormConfig` object can be created manually, or a `SendFormConfigService` can be used to create -it. A default implementation of the `SendFormConfigService` exists in the `@bitwarden/send-ui` -library. - - - - diff --git a/libs/tools/send/send-ui/src/send-form/send-form.stories.ts b/libs/tools/send/send-ui/src/send-form/send-form.stories.ts deleted file mode 100644 index 2c47bd62626..00000000000 --- a/libs/tools/send/send-ui/src/send-form/send-form.stories.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { importProvidersFrom } from "@angular/core"; -import { action } from "@storybook/addon-actions"; -import { - applicationConfig, - componentWrapperDecorator, - Meta, - moduleMetadata, - StoryObj, -} from "@storybook/angular"; - -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { Send } from "@bitwarden/common/tools/send/models/domain/send"; -import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; -import { AsyncActionsModule, ButtonModule, ToastService } from "@bitwarden/components"; -import { SendFormConfig } from "@bitwarden/send-ui"; -// FIXME: remove `/apps` import from `/libs` -// eslint-disable-next-line import/no-restricted-paths -import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests"; - -import { SendFormService } from "./abstractions/send-form.service"; -import { SendFormComponent } from "./components/send-form.component"; -import { SendFormModule } from "./send-form.module"; - -const defaultConfig: SendFormConfig = { - mode: "add", - sendType: SendType.Text, - areSendsAllowed: true, - originalSend: { - id: "123", - name: "Test Send", - notes: "Example notes", - } as unknown as Send, -}; - -class TestAddEditFormService implements SendFormService { - decryptSend(): Promise { - return Promise.resolve(defaultConfig.originalSend as any); - } - async saveSend(send: SendView, file: File | ArrayBuffer): Promise { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return send; - } -} - -const actionsData = { - onSave: action("onSave"), -}; - -export default { - title: "Tools/Send Form", - component: SendFormComponent, - decorators: [ - moduleMetadata({ - imports: [SendFormModule, AsyncActionsModule, ButtonModule], - providers: [ - { - provide: SendFormService, - useClass: TestAddEditFormService, - }, - { - provide: ToastService, - useValue: { - showToast: action("showToast"), - }, - }, - ], - }), - componentWrapperDecorator( - (story) => `
${story}
`, - ), - applicationConfig({ - providers: [importProvidersFrom(PreloadedEnglishI18nModule)], - }), - ], - args: { - config: defaultConfig, - }, - argTypes: { - config: { - description: "The configuration object for the form.", - }, - }, -} as Meta; - -type Story = StoryObj; - -export const Default: Story = { - render: (args) => { - return { - props: { - onSave: actionsData.onSave, - ...args, - }, - template: /*html*/ ` - - - `, - }; - }, -}; - -export const Edit: Story = { - ...Default, - args: { - config: { - ...defaultConfig, - mode: "edit", - originalSend: defaultConfig.originalSend, - }, - }, -}; - -export const PartialEdit: Story = { - ...Default, - args: { - config: { - ...defaultConfig, - mode: "partial-edit", - originalSend: defaultConfig.originalSend, - }, - }, -}; - -export const SendsHaveBeenDisabledByPolicy: Story = { - ...Default, - args: { - config: { - ...defaultConfig, - mode: "add", - areSendsAllowed: false, - originalSend: defaultConfig.originalSend, - }, - }, -}; diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts index a6febe48978..93cca2e5dbb 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts @@ -85,6 +85,9 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { /** Emits after a file has been successfully uploaded */ @Output() onUploadSuccess = new EventEmitter(); + /** Emits after a file has been successfully removed */ + @Output() onRemoveSuccess = new EventEmitter(); + cipher: CipherView; attachmentForm: CipherAttachmentForm = this.formBuilder.group({ @@ -216,5 +219,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { if (index > -1) { this.cipher.attachments.splice(index, 1); } + + this.onRemoveSuccess.emit(); } } diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts index 569a6666999..232a4b2d27b 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts @@ -7,6 +7,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventType } from "@bitwarden/common/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -39,6 +40,8 @@ describe("LoginDetailsSectionComponent", () => { let toastService: MockProxy; let totpCaptureService: MockProxy; let i18nService: MockProxy; + let configService: MockProxy; + const collect = jest.fn().mockResolvedValue(null); beforeEach(async () => { @@ -49,6 +52,7 @@ describe("LoginDetailsSectionComponent", () => { toastService = mock(); totpCaptureService = mock(); i18nService = mock(); + configService = mock(); collect.mockClear(); await TestBed.configureTestingModule({ @@ -60,6 +64,7 @@ describe("LoginDetailsSectionComponent", () => { { provide: ToastService, useValue: toastService }, { provide: TotpCaptureService, useValue: totpCaptureService }, { provide: I18nService, useValue: i18nService }, + { provide: ConfigService, useValue: configService }, { provide: EventCollectionService, useValue: { collect } }, ], }) diff --git a/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.html b/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.html new file mode 100644 index 00000000000..30259cd640c --- /dev/null +++ b/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.html @@ -0,0 +1,22 @@ + + + {{ title }} + + + + + + + + diff --git a/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.spec.ts b/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.spec.ts new file mode 100644 index 00000000000..844f15a47af --- /dev/null +++ b/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.spec.ts @@ -0,0 +1,125 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; + +import { UsernameGenerationServiceAbstraction } from "../../../../../../libs/tools/generator/extensions/legacy/src/username-generation.service.abstraction"; +import { CipherFormGeneratorComponent } from "../cipher-generator/cipher-form-generator.component"; + +import { + WebVaultGeneratorDialogComponent, + WebVaultGeneratorDialogParams, + WebVaultGeneratorDialogAction, +} from "./web-generator-dialog.component"; + +describe("WebVaultGeneratorDialogComponent", () => { + let component: WebVaultGeneratorDialogComponent; + let fixture: ComponentFixture; + + let dialogRef: MockProxy>; + let mockI18nService: MockProxy; + let passwordOptionsSubject: BehaviorSubject; + let usernameOptionsSubject: BehaviorSubject; + let mockPasswordGenerationService: MockProxy; + let mockUsernameGenerationService: MockProxy; + + beforeEach(async () => { + dialogRef = mock>(); + mockI18nService = mock(); + passwordOptionsSubject = new BehaviorSubject([{ type: "password" }]); + usernameOptionsSubject = new BehaviorSubject([{ type: "username" }]); + + mockPasswordGenerationService = mock(); + mockPasswordGenerationService.getOptions$.mockReturnValue( + passwordOptionsSubject.asObservable(), + ); + + mockUsernameGenerationService = mock(); + mockUsernameGenerationService.getOptions$.mockReturnValue( + usernameOptionsSubject.asObservable(), + ); + + const mockDialogData: WebVaultGeneratorDialogParams = { type: "password" }; + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, WebVaultGeneratorDialogComponent], + providers: [ + { + provide: DialogRef, + useValue: dialogRef, + }, + { + provide: DIALOG_DATA, + useValue: mockDialogData, + }, + { + provide: I18nService, + useValue: mockI18nService, + }, + { + provide: PlatformUtilsService, + useValue: mock(), + }, + { + provide: PasswordGenerationServiceAbstraction, + useValue: mockPasswordGenerationService, + }, + { + provide: UsernameGenerationServiceAbstraction, + useValue: mockUsernameGenerationService, + }, + { + provide: CipherFormGeneratorComponent, + useValue: { + passwordOptions$: passwordOptionsSubject.asObservable(), + usernameOptions$: usernameOptionsSubject.asObservable(), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(WebVaultGeneratorDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("initializes without errors", () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it("closes the dialog with 'canceled' result when close is called", () => { + const closeSpy = jest.spyOn(dialogRef, "close"); + + (component as any).close(); + + expect(closeSpy).toHaveBeenCalledWith({ + action: WebVaultGeneratorDialogAction.Canceled, + }); + }); + + it("closes the dialog with 'selected' result when selectValue is called", () => { + const closeSpy = jest.spyOn(dialogRef, "close"); + const generatedValue = "generated-value"; + component.onValueGenerated(generatedValue); + + (component as any).selectValue(); + + expect(closeSpy).toHaveBeenCalledWith({ + action: WebVaultGeneratorDialogAction.Selected, + generatedValue: generatedValue, + }); + }); + + it("updates generatedValue when onValueGenerated is called", () => { + const generatedValue = "new-generated-value"; + component.onValueGenerated(generatedValue); + + expect((component as any).generatedValue).toBe(generatedValue); + }); +}); diff --git a/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.ts b/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.ts new file mode 100644 index 00000000000..03a41990c86 --- /dev/null +++ b/libs/vault/src/cipher-form/components/web-generator-dialog/web-generator-dialog.component.ts @@ -0,0 +1,89 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ButtonModule, DialogService } from "@bitwarden/components"; +import { CipherFormGeneratorComponent } from "@bitwarden/vault"; + +import { DialogModule } from "../../../../../../libs/components/src/dialog"; + +export interface WebVaultGeneratorDialogParams { + type: "password" | "username"; +} + +export interface WebVaultGeneratorDialogResult { + action: WebVaultGeneratorDialogAction; + generatedValue?: string; +} + +export enum WebVaultGeneratorDialogAction { + Selected = "selected", + Canceled = "canceled", +} + +@Component({ + selector: "web-vault-generator-dialog", + templateUrl: "./web-generator-dialog.component.html", + standalone: true, + imports: [CommonModule, CipherFormGeneratorComponent, ButtonModule, DialogModule], +}) +export class WebVaultGeneratorDialogComponent { + protected title = this.i18nService.t(this.isPassword ? "passwordGenerator" : "usernameGenerator"); + protected selectButtonText = this.i18nService.t( + this.isPassword ? "useThisPassword" : "useThisUsername", + ); + + /** + * Whether the dialog is generating a password/passphrase. If false, it is generating a username. + * @protected + */ + protected get isPassword() { + return this.params.type === "password"; + } + + /** + * The currently generated value. + * @protected + */ + protected generatedValue: string = ""; + + constructor( + @Inject(DIALOG_DATA) protected params: WebVaultGeneratorDialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + ) {} + + /** + * Close the dialog without selecting a value. + */ + protected close = () => { + this.dialogRef.close({ action: WebVaultGeneratorDialogAction.Canceled }); + }; + + /** + * Close the dialog and select the currently generated value. + */ + protected selectValue = () => { + this.dialogRef.close({ + action: WebVaultGeneratorDialogAction.Selected, + generatedValue: this.generatedValue, + }); + }; + + onValueGenerated(value: string) { + this.generatedValue = value; + } + + /** + * Opens the vault generator dialog. + */ + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open( + WebVaultGeneratorDialogComponent, + { + ...config, + }, + ); + } +} diff --git a/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.spec.ts b/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.spec.ts new file mode 100644 index 00000000000..898ac8dcb7b --- /dev/null +++ b/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.spec.ts @@ -0,0 +1,88 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component"; + +import { WebCipherFormGenerationService } from "./web-cipher-form-generation.service"; + +describe("WebCipherFormGenerationService", () => { + let service: WebCipherFormGenerationService; + let dialogService: jest.Mocked; + let closed = of({}); + const close = jest.fn(); + const dialogRef = { + close, + get closed() { + return closed; + }, + } as unknown as DialogRef; + + beforeEach(() => { + dialogService = mock(); + + TestBed.configureTestingModule({ + providers: [ + WebCipherFormGenerationService, + { provide: DialogService, useValue: dialogService }, + ], + }); + + service = TestBed.inject(WebCipherFormGenerationService); + }); + + it("creates without error", () => { + expect(service).toBeTruthy(); + }); + + describe("generatePassword", () => { + it("opens the password generator dialog and returns the generated value", async () => { + const generatedValue = "generated-password"; + closed = of({ action: "generated", generatedValue }); + dialogService.open.mockReturnValue(dialogRef); + + const result = await service.generatePassword(); + + expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, { + data: { type: "password" }, + }); + expect(result).toBe(generatedValue); + }); + + it("returns null if the dialog is canceled", async () => { + closed = of({ action: "canceled" }); + dialogService.open.mockReturnValue(dialogRef); + + const result = await service.generatePassword(); + + expect(result).toBeNull(); + }); + }); + + describe("generateUsername", () => { + it("opens the username generator dialog and returns the generated value", async () => { + const generatedValue = "generated-username"; + closed = of({ action: "generated", generatedValue }); + dialogService.open.mockReturnValue(dialogRef); + + const result = await service.generateUsername(); + + expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, { + data: { type: "username" }, + }); + expect(result).toBe(generatedValue); + }); + + it("returns null if the dialog is canceled", async () => { + closed = of({ action: "canceled" }); + dialogService.open.mockReturnValue(dialogRef); + + const result = await service.generateUsername(); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.ts b/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.ts new file mode 100644 index 00000000000..cfa0b28dbf0 --- /dev/null +++ b/libs/vault/src/cipher-form/services/web-cipher-form-generation.service.ts @@ -0,0 +1,40 @@ +import { inject, Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; +import { CipherFormGenerationService } from "@bitwarden/vault"; + +import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component"; + +@Injectable() +export class WebCipherFormGenerationService implements CipherFormGenerationService { + private dialogService = inject(DialogService); + + async generatePassword(): Promise { + const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, { + data: { type: "password" }, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if (result == null || result.action === "canceled") { + return null; + } + + return result.generatedValue; + } + + async generateUsername(): Promise { + const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, { + data: { type: "username" }, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if (result == null || result.action === "canceled") { + return null; + } + + return result.generatedValue; + } +} diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html index 88a59d9cc42..43be62f8c69 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html @@ -97,7 +97,7 @@ bitBadge variant="success" class="tw-ml-2 tw-cursor-pointer" - (click)="getPremium()" + (click)="getPremium(cipher.organizationId)" slot="end" > {{ "premium" | i18n }} diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts index c6957b1848e..b05d3318c3c 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts @@ -1,6 +1,5 @@ import { CommonModule, DatePipe } from "@angular/common"; import { Component, inject, Input } from "@angular/core"; -import { Router } from "@angular/router"; import { Observable, shareReplay } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -20,6 +19,7 @@ import { ColorPasswordModule, } from "@bitwarden/components"; +import { PremiumUpgradePromptService } from "../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service"; import { BitTotpCountdownComponent } from "../../components/totp-countdown/totp-countdown.component"; import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only-cipher-card.component"; @@ -61,8 +61,8 @@ export class LoginCredentialsViewComponent { constructor( private billingAccountProfileStateService: BillingAccountProfileStateService, - private router: Router, private i18nService: I18nService, + private premiumUpgradeService: PremiumUpgradePromptService, private eventCollectionService: EventCollectionService, ) {} @@ -75,8 +75,8 @@ export class LoginCredentialsViewComponent { return `${dateCreated} ${creationDate}`; } - async getPremium() { - await this.router.navigate(["/premium"]); + async getPremium(organizationId?: string) { + await this.premiumUpgradeService.promptForPremium(organizationId); } async pwToggleValue(passwordVisible: boolean) { diff --git a/package-lock.json b/package-lock.json index e71763cde27..95e8fcb50b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -129,7 +129,7 @@ "copy-webpack-plugin": "12.0.2", "cross-env": "7.0.3", "css-loader": "7.1.2", - "electron": "32.0.2", + "electron": "32.1.1", "electron-builder": "24.13.3", "electron-log": "5.0.1", "electron-reload": "2.0.0-alpha.1", @@ -16414,9 +16414,9 @@ } }, "node_modules/electron": { - "version": "32.0.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-32.0.2.tgz", - "integrity": "sha512-nmZblq8wW3HZ17MAyaUuiMI9Mb0Cgc7UR3To85h/rVopbfyF5s34NxtK4gvyRfYPxpDGP4k+HoQIPniPPrdE3w==", + "version": "32.1.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-32.1.1.tgz", + "integrity": "sha512-NlWvG6kXOJbZbELmzP3oV7u50I3NHYbCeh+AkUQ9vGyP7b74cFMx9HdTzejODeztW1jhr3SjIBbUZzZ45zflfQ==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index 8e5f38c62ef..aafc92bbd3d 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "copy-webpack-plugin": "12.0.2", "cross-env": "7.0.3", "css-loader": "7.1.2", - "electron": "32.0.2", + "electron": "32.1.1", "electron-builder": "24.13.3", "electron-log": "5.0.1", "electron-reload": "2.0.0-alpha.1",