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 @@
}
+