diff --git a/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts index f7fe9ee1494..e564ca0ceea 100644 --- a/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts @@ -218,6 +218,8 @@ export class ItemMoreOptionsComponent { return; } + //this tab checking should be moved into the vault-popup-autofill service in case the current tab is changed + //ticket: https://bitwarden.atlassian.net/browse/PM-32467 const currentTab = await firstValueFrom(this.vaultPopupAutofillService.currentAutofillTab$); if (!currentTab?.url) { diff --git a/apps/browser/src/vault/popup/components/vault/view/view.component.html b/apps/browser/src/vault/popup/components/vault/view/view.component.html index a3d65522022..0e07497cea9 100644 --- a/apps/browser/src/vault/popup/components/vault/view/view.component.html +++ b/apps/browser/src/vault/popup/components/vault/view/view.component.html @@ -11,7 +11,21 @@ @if (cipher) { - + + @if (showAutofillButton()) { + + } + } diff --git a/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts b/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts index 5c94af0205d..af31dee7550 100644 --- a/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, fakeAsync, flush, TestBed, tick } from "@angular/core import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; -import { of, Subject } from "rxjs"; +import { BehaviorSubject, of, Subject } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -18,6 +18,7 @@ import { import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { EventType } from "@bitwarden/common/enums"; +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -33,6 +34,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { TaskService } from "@bitwarden/common/vault/tasks"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -47,6 +49,10 @@ import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-uti import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service"; +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, +} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component"; import { ViewComponent } from "./view.component"; @@ -62,6 +68,7 @@ describe("ViewComponent", () => { const mockNavigate = jest.fn(); const collect = jest.fn().mockResolvedValue(null); const doAutofill = jest.fn().mockResolvedValue(true); + const doAutofillAndSave = jest.fn().mockResolvedValue(true); const copy = jest.fn().mockResolvedValue(true); const back = jest.fn().mockResolvedValue(null); const openSimpleDialog = jest.fn().mockResolvedValue(true); @@ -69,6 +76,8 @@ describe("ViewComponent", () => { const showToast = jest.fn(); const showPasswordPrompt = jest.fn().mockResolvedValue(true); const getFeatureFlag$ = jest.fn().mockReturnValue(of(true)); + const getFeatureFlag = jest.fn().mockResolvedValue(true); + const currentAutofillTab$ = of({ url: "https://example.com", id: 1 }); const mockCipher = { id: "122-333-444", @@ -87,8 +96,12 @@ describe("ViewComponent", () => { const mockPasswordRepromptService = { showPasswordPrompt, }; + const autofillAllowed$ = new BehaviorSubject(true); const mockVaultPopupAutofillService = { doAutofill, + doAutofillAndSave, + currentAutofillTab$, + autofillAllowed$, }; const mockCopyCipherFieldService = { copy, @@ -112,12 +125,15 @@ describe("ViewComponent", () => { mockNavigate.mockClear(); collect.mockClear(); doAutofill.mockClear(); + doAutofillAndSave.mockClear(); copy.mockClear(); stop.mockClear(); openSimpleDialog.mockClear(); back.mockClear(); showToast.mockClear(); showPasswordPrompt.mockClear(); + getFeatureFlag.mockClear(); + autofillAllowed$.next(true); cipherArchiveService.hasArchiveFlagEnabled$ = of(true); cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); cipherArchiveService.archiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); @@ -137,7 +153,7 @@ describe("ViewComponent", () => { { provide: VaultPopupScrollPositionService, useValue: { stop } }, { provide: VaultPopupAutofillService, useValue: mockVaultPopupAutofillService }, { provide: ToastService, useValue: { showToast } }, - { provide: ConfigService, useValue: { getFeatureFlag$ } }, + { provide: ConfigService, useValue: { getFeatureFlag$, getFeatureFlag } }, { provide: I18nService, useValue: { @@ -203,6 +219,8 @@ describe("ViewComponent", () => { provide: DomainSettingsService, useValue: { showFavicons$: of(true), + resolvedDefaultUriMatchStrategy$: of(UriMatchStrategy.Domain), + getUrlEquivalentDomains: jest.fn().mockReturnValue(of([])), }, }, { @@ -697,4 +715,452 @@ describe("ViewComponent", () => { expect(badge).toBeFalsy(); }); }); + + describe("showAutofillButton", () => { + beforeEach(() => { + component.cipher = { ...mockCipher, type: CipherType.Login } as CipherView; + }); + + it("returns true when feature flag is enabled, cipher is a login, and not archived/deleted", fakeAsync(() => { + getFeatureFlag$.mockReturnValue(of(true)); + autofillAllowed$.next(true); + + // Recreate component to pick up the signal values + fixture = TestBed.createComponent(ViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.cipher = { + ...mockCipher, + type: CipherType.Login, + isArchived: false, + isDeleted: false, + } as CipherView; + + flush(); + + const result = component.showAutofillButton(); + + expect(result).toBe(true); + })); + + it("returns true for Card type when conditions are met", fakeAsync(() => { + getFeatureFlag$.mockReturnValue(of(true)); + autofillAllowed$.next(true); + + // Recreate component to pick up the signal values + fixture = TestBed.createComponent(ViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.cipher = { + ...mockCipher, + type: CipherType.Card, + isArchived: false, + isDeleted: false, + } as CipherView; + + flush(); + + const result = component.showAutofillButton(); + + expect(result).toBe(true); + })); + + it("returns true for Identity type when conditions are met", fakeAsync(() => { + getFeatureFlag$.mockReturnValue(of(true)); + autofillAllowed$.next(true); + + // Recreate component to pick up the signal values + fixture = TestBed.createComponent(ViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.cipher = { + ...mockCipher, + type: CipherType.Identity, + isArchived: false, + isDeleted: false, + } as CipherView; + + flush(); + + const result = component.showAutofillButton(); + + expect(result).toBe(true); + })); + + it("returns false when feature flag is disabled", fakeAsync(() => { + getFeatureFlag$.mockReturnValue(of(false)); + + // Recreate component to pick up the new feature flag value + fixture = TestBed.createComponent(ViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.cipher = { + ...mockCipher, + type: CipherType.Login, + isArchived: false, + isDeleted: false, + } as CipherView; + + flush(); + + const result = component.showAutofillButton(); + + expect(result).toBe(false); + })); + + it("returns false when autofill is not allowed", fakeAsync(() => { + getFeatureFlag$.mockReturnValue(of(true)); + autofillAllowed$.next(false); + + // Recreate component to pick up the new autofillAllowed value + fixture = TestBed.createComponent(ViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.cipher = { + ...mockCipher, + type: CipherType.Login, + isArchived: false, + isDeleted: false, + } as CipherView; + + flush(); + + const result = component.showAutofillButton(); + + expect(result).toBe(false); + })); + + it("returns false for SecureNote type", fakeAsync(() => { + getFeatureFlag$.mockReturnValue(of(true)); + autofillAllowed$.next(true); + + // Recreate component to pick up the signal values + fixture = TestBed.createComponent(ViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.cipher = { + ...mockCipher, + type: CipherType.SecureNote, + isArchived: false, + isDeleted: false, + } as CipherView; + + flush(); + + const result = component.showAutofillButton(); + + expect(result).toBe(false); + })); + + it("returns false for SshKey type", fakeAsync(() => { + getFeatureFlag$.mockReturnValue(of(true)); + autofillAllowed$.next(true); + + // Recreate component to pick up the signal values + fixture = TestBed.createComponent(ViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.cipher = { + ...mockCipher, + type: CipherType.SshKey, + isArchived: false, + isDeleted: false, + } as CipherView; + + flush(); + + const result = component.showAutofillButton(); + + expect(result).toBe(false); + })); + + it("returns false when cipher is archived", fakeAsync(() => { + getFeatureFlag$.mockReturnValue(of(true)); + autofillAllowed$.next(true); + + // Recreate component to pick up the signal values + fixture = TestBed.createComponent(ViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.cipher = { + ...mockCipher, + type: CipherType.Login, + isArchived: true, + isDeleted: false, + } as CipherView; + + flush(); + + const result = component.showAutofillButton(); + + expect(result).toBe(false); + })); + + it("returns false when cipher is deleted", fakeAsync(() => { + getFeatureFlag$.mockReturnValue(of(true)); + autofillAllowed$.next(true); + + // Recreate component to pick up the signal values + fixture = TestBed.createComponent(ViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.cipher = { + ...mockCipher, + type: CipherType.Login, + isArchived: false, + isDeleted: true, + } as CipherView; + + flush(); + + const result = component.showAutofillButton(); + + expect(result).toBe(false); + })); + }); + + describe("doAutofill", () => { + let dialogService: DialogService; + const originalCurrentAutofillTab$ = currentAutofillTab$; + + beforeEach(() => { + dialogService = TestBed.inject(DialogService); + + component.cipher = { + ...mockCipher, + type: CipherType.Login, + login: { + username: "test", + password: "test", + uris: [ + { + uri: "https://example.com", + match: null, + } as LoginUriView, + ], + }, + edit: true, + } as CipherView; + }); + + afterEach(() => { + // Restore original observable to prevent test pollution + mockVaultPopupAutofillService.currentAutofillTab$ = originalCurrentAutofillTab$; + }); + + it("returns early when feature flag is disabled", async () => { + getFeatureFlag.mockResolvedValue(false); + + await component.doAutofill(); + + expect(doAutofill).not.toHaveBeenCalled(); + expect(openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows exact match dialog when no URIs and default strategy is Exact", async () => { + getFeatureFlag.mockResolvedValue(true); + component.cipher.login.uris = []; + (component as any).uriMatchStrategy$ = of(UriMatchStrategy.Exact); + + await component.doAutofill(); + + expect(openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "cannotAutofill" }, + content: { key: "cannotAutofillExactMatch" }, + type: "info", + acceptButtonText: { key: "okay" }, + cancelButtonText: null, + }); + expect(doAutofill).not.toHaveBeenCalled(); + }); + + it("shows exact match dialog when all URIs have exact match strategy", async () => { + getFeatureFlag.mockResolvedValue(true); + component.cipher.login.uris = [ + { uri: "https://example.com", match: UriMatchStrategy.Exact } as LoginUriView, + { uri: "https://example2.com", match: UriMatchStrategy.Exact } as LoginUriView, + ]; + + await component.doAutofill(); + + expect(openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "cannotAutofill" }, + content: { key: "cannotAutofillExactMatch" }, + type: "info", + acceptButtonText: { key: "okay" }, + cancelButtonText: null, + }); + expect(doAutofill).not.toHaveBeenCalled(); + }); + + it("shows error dialog when current tab URL is unavailable", async () => { + getFeatureFlag.mockResolvedValue(true); + mockVaultPopupAutofillService.currentAutofillTab$ = of({ url: null, id: 1 }); + + await component.doAutofill(); + + expect(openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "error" }, + content: { key: "errorGettingAutoFillData" }, + type: "danger", + }); + expect(doAutofill).not.toHaveBeenCalled(); + }); + + it("autofills directly when domain matches", async () => { + getFeatureFlag.mockResolvedValue(true); + jest.spyOn(component as any, "_domainMatched").mockResolvedValue(true); + + await component.doAutofill(); + + expect(doAutofill).toHaveBeenCalledWith(component.cipher, true, true); + }); + + it("shows confirmation dialog when domain does not match", async () => { + getFeatureFlag.mockResolvedValue(true); + jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false); + + const mockDialogRef = { + closed: of(AutofillConfirmationDialogResult.Canceled), + }; + jest.spyOn(AutofillConfirmationDialogComponent, "open").mockReturnValue(mockDialogRef as any); + + await component.doAutofill(); + + expect(AutofillConfirmationDialogComponent.open).toHaveBeenCalledWith(dialogService, { + data: { + currentUrl: "https://example.com", + savedUrls: ["https://example.com"], + viewOnly: false, + }, + }); + }); + + it("does not autofill when user cancels confirmation dialog", async () => { + getFeatureFlag.mockResolvedValue(true); + jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false); + + const mockDialogRef = { + closed: of(AutofillConfirmationDialogResult.Canceled), + }; + jest.spyOn(AutofillConfirmationDialogComponent, "open").mockReturnValue(mockDialogRef as any); + + await component.doAutofill(); + + expect(doAutofill).not.toHaveBeenCalled(); + expect(doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("autofills only when user selects AutofilledOnly", async () => { + getFeatureFlag.mockResolvedValue(true); + jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false); + + const mockDialogRef = { + closed: of(AutofillConfirmationDialogResult.AutofilledOnly), + }; + jest.spyOn(AutofillConfirmationDialogComponent, "open").mockReturnValue(mockDialogRef as any); + + await component.doAutofill(); + + expect(doAutofill).toHaveBeenCalledWith(component.cipher, true, true); + expect(doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("autofills and saves URL when user selects AutofillAndUrlAdded", async () => { + getFeatureFlag.mockResolvedValue(true); + jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false); + + const mockDialogRef = { + closed: of(AutofillConfirmationDialogResult.AutofillAndUrlAdded), + }; + jest.spyOn(AutofillConfirmationDialogComponent, "open").mockReturnValue(mockDialogRef as any); + + await component.doAutofill(); + + expect(doAutofillAndSave).toHaveBeenCalledWith(component.cipher, true, true); + expect(doAutofill).not.toHaveBeenCalled(); + }); + + it("passes viewOnly as true when cipher is not editable", async () => { + getFeatureFlag.mockResolvedValue(true); + jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false); + component.cipher.edit = false; + + const mockDialogRef = { + closed: of(AutofillConfirmationDialogResult.Canceled), + }; + const openSpy = jest + .spyOn(AutofillConfirmationDialogComponent, "open") + .mockReturnValue(mockDialogRef as any); + + await component.doAutofill(); + + expect(openSpy).toHaveBeenCalledWith(dialogService, { + data: { + currentUrl: "https://example.com", + savedUrls: ["https://example.com"], + viewOnly: true, + }, + }); + }); + + it("filters out URIs without uri property", async () => { + getFeatureFlag.mockResolvedValue(true); + jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false); + component.cipher.login.uris = [ + { uri: "https://example.com" } as LoginUriView, + { uri: null } as LoginUriView, + { uri: "https://example2.com" } as LoginUriView, + ]; + + const mockDialogRef = { + closed: of(AutofillConfirmationDialogResult.Canceled), + }; + const openSpy = jest + .spyOn(AutofillConfirmationDialogComponent, "open") + .mockReturnValue(mockDialogRef as any); + + await component.doAutofill(); + + expect(openSpy).toHaveBeenCalledWith(dialogService, { + data: { + currentUrl: "https://example.com", + savedUrls: ["https://example.com", "https://example2.com"], + viewOnly: false, + }, + }); + }); + + it("handles cipher with no login uris gracefully", async () => { + getFeatureFlag.mockResolvedValue(true); + jest.spyOn(component as any, "_domainMatched").mockResolvedValue(false); + component.cipher.login.uris = null; + + const mockDialogRef = { + closed: of(AutofillConfirmationDialogResult.Canceled), + }; + const openSpy = jest + .spyOn(AutofillConfirmationDialogComponent, "open") + .mockReturnValue(mockDialogRef as any); + + await component.doAutofill(); + + expect(openSpy).toHaveBeenCalledWith(dialogService, { + data: { + currentUrl: "https://example.com", + savedUrls: [], + viewOnly: false, + }, + }); + }); + }); }); diff --git a/apps/browser/src/vault/popup/components/vault/view/view.component.ts b/apps/browser/src/vault/popup/components/vault/view/view.component.ts index 48402a957d6..5166dbcf8db 100644 --- a/apps/browser/src/vault/popup/components/vault/view/view.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view/view.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom, Observable, switchMap, of, map } from "rxjs"; @@ -21,7 +21,11 @@ import { SHOW_AUTOFILL_BUTTON, UPDATE_PASSWORD, } from "@bitwarden/common/autofill/constants"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { EventType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -32,6 +36,7 @@ import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { AsyncActionsModule, @@ -66,6 +71,10 @@ import { VaultPopupAutofillService } from "../../../services/vault-popup-autofil import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service"; import { closeViewVaultItemPopout, VaultPopoutType } from "../../../utils/vault-popout-window"; import { ROUTES_AFTER_EDIT_DELETION } from "../add-edit/add-edit.component"; +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, +} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component"; /** * The types of actions that can be triggered when loading the view vault item popout via the @@ -118,6 +127,13 @@ export class ViewComponent { senderTabId?: number; routeAfterDeletion?: ROUTES_AFTER_EDIT_DELETION; + //feature flag + private readonly pm30521FeatureFlag = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM30521_AutofillButtonViewLoginScreen), + ); + + private readonly autofillAllowed = toSignal(this.vaultPopupAutofillService.autofillAllowed$); + private uriMatchStrategy$ = this.domainSettingsService.resolvedDefaultUriMatchStrategy$; protected showFooter$: Observable; protected userCanArchive$ = this.accountService.activeAccount$ .pipe(getUserId) @@ -142,6 +158,8 @@ export class ViewComponent { private popupScrollPositionService: VaultPopupScrollPositionService, private archiveService: CipherArchiveService, private archiveCipherUtilsService: ArchiveCipherUtilitiesService, + private domainSettingsService: DomainSettingsService, + private configService: ConfigService, ) { this.subscribeToParams(); } @@ -322,6 +340,113 @@ export class ViewComponent { : this.cipherService.softDeleteWithServer(this.cipher.id, this.activeUserId); } + showAutofillButton(): boolean { + //feature flag + if (!this.pm30521FeatureFlag()) { + return false; + } + + if (!this.autofillAllowed()) { + return false; + } + + const validAutofillType = ( + [CipherType.Login, CipherType.Card, CipherType.Identity] as CipherType[] + ).includes(CipherViewLikeUtils.getType(this.cipher)); + + return validAutofillType && !(this.cipher.isArchived || this.cipher.isDeleted); + } + + async doAutofill() { + //feature flag + if ( + !(await this.configService.getFeatureFlag(FeatureFlag.PM30521_AutofillButtonViewLoginScreen)) + ) { + return; + } + + //for non login types that are still auto-fillable + if (CipherViewLikeUtils.getType(this.cipher) !== CipherType.Login) { + await this.vaultPopupAutofillService.doAutofill(this.cipher, true, true); + return; + } + + const uris = this.cipher.login?.uris ?? []; + const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$); + + const showExactMatchDialog = + uris.length === 0 + ? uriMatchStrategy === UriMatchStrategy.Exact + : // all saved URIs are exact match + uris.every((u) => (u.match ?? uriMatchStrategy) === UriMatchStrategy.Exact); + + if (showExactMatchDialog) { + await this.dialogService.openSimpleDialog({ + title: { key: "cannotAutofill" }, + content: { key: "cannotAutofillExactMatch" }, + type: "info", + acceptButtonText: { key: "okay" }, + cancelButtonText: null, + }); + return; + } + + //this tab checking should be moved into the vault-popup-autofill service in case the current tab is changed + //ticket: https://bitwarden.atlassian.net/browse/PM-32467 + const currentTab = await firstValueFrom(this.vaultPopupAutofillService.currentAutofillTab$); + + if (!currentTab?.url) { + await this.dialogService.openSimpleDialog({ + title: { key: "error" }, + content: { key: "errorGettingAutoFillData" }, + type: "danger", + }); + return; + } + + if (await this._domainMatched(currentTab)) { + await this.vaultPopupAutofillService.doAutofill(this.cipher, true, true); + return; + } + + const ref = AutofillConfirmationDialogComponent.open(this.dialogService, { + data: { + currentUrl: currentTab?.url || "", + savedUrls: this.cipher.login?.uris?.filter((u) => u.uri).map((u) => u.uri!) ?? [], + viewOnly: !this.cipher.edit, + }, + }); + + const result = await firstValueFrom(ref.closed); + + switch (result) { + case AutofillConfirmationDialogResult.Canceled: + return; + case AutofillConfirmationDialogResult.AutofilledOnly: + await this.vaultPopupAutofillService.doAutofill(this.cipher, true, true); + return; + case AutofillConfirmationDialogResult.AutofillAndUrlAdded: + await this.vaultPopupAutofillService.doAutofillAndSave(this.cipher, true, true); + return; + } + } + + private async _domainMatched(currentTab: chrome.tabs.Tab): Promise { + const equivalentDomains = await firstValueFrom( + this.domainSettingsService.getUrlEquivalentDomains(currentTab?.url), + ); + const defaultMatch = await firstValueFrom( + this.domainSettingsService.resolvedDefaultUriMatchStrategy$, + ); + + return CipherViewLikeUtils.matchesUri( + this.cipher, + currentTab?.url, + equivalentDomains, + defaultMatch, + ); + } + /** * Handles the load action for the view vault item popout. These actions are typically triggered * via the extension context menu. It is necessary to render the view for items that have password diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 0cd97eb7f2e..c9e2fa17dd6 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -70,6 +70,7 @@ export enum FeatureFlag { BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems", PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk", + PM30521_AutofillButtonViewLoginScreen = "pm-30521-autofill-button-view-login-screen", PM29438_WelcomeDialogWithExtensionPrompt = "pm-29438-welcome-dialog-with-extension-prompt", PM29438_DialogWithExtensionPromptAccountAge = "pm-29438-dialog-with-extension-prompt-account-age", PM29437_WelcomeDialog = "pm-29437-welcome-dialog-no-ext-prompt", @@ -139,6 +140,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.BrowserPremiumSpotlight]: FALSE, [FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE, [FeatureFlag.MigrateMyVaultToMyItems]: FALSE, + [FeatureFlag.PM30521_AutofillButtonViewLoginScreen]: FALSE, [FeatureFlag.PM29438_WelcomeDialogWithExtensionPrompt]: FALSE, [FeatureFlag.PM29438_DialogWithExtensionPromptAccountAge]: 5, [FeatureFlag.PM29437_WelcomeDialog]: FALSE, diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index 05d2ecede72..813d1452225 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -37,6 +37,7 @@ [folder]="folder()" [hideOwner]="isAdminConsole()" > + diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.html b/libs/vault/src/cipher-view/item-details/item-details-v2.component.html index edf17f0921c..5687da0a212 100644 --- a/libs/vault/src/cipher-view/item-details/item-details-v2.component.html +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.html @@ -89,5 +89,6 @@ } +