1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-23 03:22:50 +02:00

[PM-9190] Edit Login - Autofill Options (#10274)

* [PM-8524] Update appA11yTitle to keep attributes in sync after first render

* [PM-8524] Introduce UriOptionComponent

* [PM-9190] Introduce AutofillOptionsComponent

* [PM-9190] Add AutofillOptions to LoginDetailsSection

* [PM-9190] Add autofill options component unit tests

* [PM-9190] Add UriOptionComponent unit tests

* [PM-9190] Add missing translations

* [PM-9190] Add autofill on page load field

* [PM-9190] Ensure updatedCipherView is completely separate from originalCipherView

* [CL-348] Do not override items if there are no OptionComponents available

* [PM-9190] Mock AutoFillOptions component in Login Details tests

* [PM-9190] Cleanup storybook and missing web translations

* [PM-9190] Ensure storybook decryptCipher returns a separate object
This commit is contained in:
Shane Melton 2024-08-01 08:35:04 -07:00 committed by GitHub
parent ffc9022f54
commit 0d76835cd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 912 additions and 7 deletions

View File

@ -3828,6 +3828,52 @@
"authenticatorKey": { "authenticatorKey": {
"message": "Authenticator key" "message": "Authenticator key"
}, },
"autofillOptions": {
"message": "Auto-fill options"
},
"websiteUri": {
"message": "Website (URI)"
},
"websiteAdded": {
"message": "Website added"
},
"addWebsite": {
"message": "Add website"
},
"deleteWebsite": {
"message": "Delete website"
},
"defaultLabel": {
"message": "Default ($VALUE$)",
"description": "A label that indicates the default value for a field with the current default value in parentheses.",
"placeholders": {
"value": {
"content": "$1",
"example": "Base domain"
}
}
},
"showMatchDetection": {
"message": "Show match detection $WEBSITE$",
"placeholders": {
"website": {
"content": "$1",
"example": "https://example.com"
}
}
},
"hideMatchDetection": {
"message": "Hide match detection $WEBSITE$",
"placeholders": {
"website": {
"content": "$1",
"example": "https://example.com"
}
}
},
"autoFillOnPageLoad": {
"message": "Autofill on page load?"
},
"cardDetails": { "cardDetails": {
"message": "Card details" "message": "Card details"
}, },

View File

@ -5,6 +5,7 @@ config.content = [
"./src/**/*.{html,ts}", "./src/**/*.{html,ts}",
"../../libs/components/src/**/*.{html,ts}", "../../libs/components/src/**/*.{html,ts}",
"../../libs/auth/src/**/*.{html,ts}", "../../libs/auth/src/**/*.{html,ts}",
"../../libs/vault/src/**/*.{html,ts}",
"../../libs/angular/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}",
"../../libs/vault/src/**/*.{html,ts}", "../../libs/vault/src/**/*.{html,ts}",
]; ];

View File

@ -48,6 +48,52 @@
"authenticatorKey": { "authenticatorKey": {
"message": "Authenticator key" "message": "Authenticator key"
}, },
"autofillOptions": {
"message": "Auto-fill options"
},
"websiteUri": {
"message": "Website (URI)"
},
"websiteAdded": {
"message": "Website added"
},
"addWebsite": {
"message": "Add website"
},
"deleteWebsite": {
"message": "Delete website"
},
"defaultLabel": {
"message": "Default ($VALUE$)",
"description": "A label that indicates the default value for a field with the current default value in parentheses.",
"placeholders": {
"value": {
"content": "$1",
"example": "Base domain"
}
}
},
"showMatchDetection": {
"message": "Show match detection $WEBSITE$",
"placeholders": {
"website": {
"content": "$1",
"example": "https://example.com"
}
}
},
"hideMatchDetection": {
"message": "Hide match detection $WEBSITE$",
"placeholders": {
"website": {
"content": "$1",
"example": "https://example.com"
}
}
},
"autoFillOnPageLoad": {
"message": "Autofill on page load?"
},
"number": { "number": {
"message": "Number" "message": "Number"
}, },
@ -8024,6 +8070,9 @@
} }
} }
}, },
"addField": {
"message": "Add field"
},
"items": { "items": {
"message": "Items" "message": "Items"
}, },

View File

@ -1,14 +1,17 @@
import { Directive, ElementRef, Input, Renderer2 } from "@angular/core"; import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core";
@Directive({ @Directive({
selector: "[appA11yTitle]", selector: "[appA11yTitle]",
}) })
export class A11yTitleDirective { export class A11yTitleDirective implements OnInit {
@Input() set appA11yTitle(title: string) { @Input() set appA11yTitle(title: string) {
this.title = title; this.title = title;
this.setAttributes();
} }
private title: string; private title: string;
private originalTitle: string | null;
private originalAriaLabel: string | null;
constructor( constructor(
private el: ElementRef, private el: ElementRef,
@ -16,10 +19,16 @@ export class A11yTitleDirective {
) {} ) {}
ngOnInit() { ngOnInit() {
if (!this.el.nativeElement.hasAttribute("title")) { this.originalTitle = this.el.nativeElement.getAttribute("title");
this.originalAriaLabel = this.el.nativeElement.getAttribute("aria-label");
this.setAttributes();
}
private setAttributes() {
if (this.originalTitle === null) {
this.renderer.setAttribute(this.el.nativeElement, "title", this.title); this.renderer.setAttribute(this.el.nativeElement, "title", this.title);
} }
if (!this.el.nativeElement.hasAttribute("aria-label")) { if (this.originalAriaLabel === null) {
this.renderer.setAttribute(this.el.nativeElement, "aria-label", this.title); this.renderer.setAttribute(this.el.nativeElement, "aria-label", this.title);
} }
} }

View File

@ -50,6 +50,9 @@ export class SelectComponent<T> implements BitFormFieldControl, ControlValueAcce
@ContentChildren(OptionComponent) @ContentChildren(OptionComponent)
protected set options(value: QueryList<OptionComponent<T>>) { protected set options(value: QueryList<OptionComponent<T>>) {
if (value == null || value.length == 0) {
return;
}
this.items = value.toArray(); this.items = value.toArray();
this.selectedOption = this.findSelectedOption(this.items, this.selectedValue); this.selectedOption = this.findSelectedOption(this.items, this.selectedValue);
} }

View File

@ -2,6 +2,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherFormConfig } from "@bitwarden/vault"; import { CipherFormConfig } from "@bitwarden/vault";
import { AdditionalOptionsSectionComponent } from "./components/additional-options/additional-options-section.component"; import { AdditionalOptionsSectionComponent } from "./components/additional-options/additional-options-section.component";
import { AutofillOptionsComponent } from "./components/autofill-options/autofill-options.component";
import { CardDetailsSectionComponent } from "./components/card-details-section/card-details-section.component"; import { CardDetailsSectionComponent } from "./components/card-details-section/card-details-section.component";
import { CustomFieldsComponent } from "./components/custom-fields/custom-fields.component"; import { CustomFieldsComponent } from "./components/custom-fields/custom-fields.component";
import { IdentitySectionComponent } from "./components/identity/identity.component"; import { IdentitySectionComponent } from "./components/identity/identity.component";
@ -16,6 +17,7 @@ export type CipherForm = {
itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"]; itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"];
additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"]; additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"];
loginDetails?: LoginDetailsSectionComponent["loginDetailsForm"]; loginDetails?: LoginDetailsSectionComponent["loginDetailsForm"];
autoFillOptions?: AutofillOptionsComponent["autofillOptionsForm"];
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"]; cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
identityDetails?: IdentitySectionComponent["identityForm"]; identityDetails?: IdentitySectionComponent["identityForm"];
customFields?: CustomFieldsComponent["customFieldsForm"]; customFields?: CustomFieldsComponent["customFieldsForm"];

View File

@ -11,6 +11,9 @@ import { BehaviorSubject } from "rxjs";
import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@ -96,7 +99,7 @@ const defaultConfig: CipherFormConfig = {
class TestAddEditFormService implements CipherFormService { class TestAddEditFormService implements CipherFormService {
decryptCipher(): Promise<CipherView> { decryptCipher(): Promise<CipherView> {
return Promise.resolve(defaultConfig.originalCipher as any); return Promise.resolve({ ...defaultConfig.originalCipher } as any);
} }
async saveCipher(cipher: CipherView): Promise<CipherView> { async saveCipher(cipher: CipherView): Promise<CipherView> {
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
@ -151,6 +154,18 @@ export default {
passwordLeaked: () => Promise.resolve(0), passwordLeaked: () => Promise.resolve(0),
}, },
}, },
{
provide: DomainSettingsService,
useValue: {
defaultUriMatchStrategy$: new BehaviorSubject(UriMatchStrategy.StartsWith),
},
},
{
provide: AutofillSettingsServiceAbstraction,
useValue: {
autofillOnPageLoadDefault$: new BehaviorSubject(true),
},
},
], ],
}), }),
componentWrapperDecorator( componentWrapperDecorator(

View File

@ -0,0 +1,36 @@
<bit-section [formGroup]="autofillOptionsForm">
<bit-section-header>
<h2 bitTypography="h5">
{{ "autofillOptions" | i18n }}
</h2>
</bit-section-header>
<bit-card>
<ng-container formArrayName="uris">
<vault-autofill-uri-option
*ngFor="let uri of uriControls; let i = index"
[formControlName]="i"
(remove)="removeUri(i)"
[canRemove]="uriControls.length > 1"
[defaultMatchDetection]="defaultMatchDetection$ | async"
></vault-autofill-uri-option>
</ng-container>
<button
type="button"
bitLink
linkType="primary"
class="tw-mb-6"
(click)="addUri({ uri: null, matchDetection: null }, true)"
*ngIf="autofillOptionsForm.enabled"
>
<i class="bwi bwi-plus tw-font-bold" aria-hidden="true"></i>
{{ "addWebsite" | i18n }}
</button>
<bit-form-field>
<bit-label>{{ "autoFillOnPageLoad" | i18n }}</bit-label>
<bit-select formControlName="autofillOnPageLoad" [items]="autofillOptions"></bit-select>
</bit-form-field>
</bit-card>
</bit-section>

View File

@ -0,0 +1,188 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { CipherFormContainer } from "../../cipher-form-container";
import { AutofillOptionsComponent } from "./autofill-options.component";
describe("AutofillOptionsComponent", () => {
let component: AutofillOptionsComponent;
let fixture: ComponentFixture<AutofillOptionsComponent>;
let cipherFormContainer: MockProxy<CipherFormContainer>;
let liveAnnouncer: MockProxy<LiveAnnouncer>;
let domainSettingsService: MockProxy<DomainSettingsService>;
let autofillSettingsService: MockProxy<AutofillSettingsServiceAbstraction>;
beforeEach(async () => {
cipherFormContainer = mock<CipherFormContainer>();
liveAnnouncer = mock<LiveAnnouncer>();
domainSettingsService = mock<DomainSettingsService>();
domainSettingsService.defaultUriMatchStrategy$ = new BehaviorSubject(null);
autofillSettingsService = mock<AutofillSettingsServiceAbstraction>();
autofillSettingsService.autofillOnPageLoadDefault$ = new BehaviorSubject(false);
await TestBed.configureTestingModule({
imports: [AutofillOptionsComponent],
providers: [
{ provide: CipherFormContainer, useValue: cipherFormContainer },
{
provide: I18nService,
useValue: { t: (...keys: string[]) => keys.filter(Boolean).join(" ") },
},
{ provide: LiveAnnouncer, useValue: liveAnnouncer },
{ provide: DomainSettingsService, useValue: domainSettingsService },
{ provide: AutofillSettingsServiceAbstraction, useValue: autofillSettingsService },
],
}).compileComponents();
fixture = TestBed.createComponent(AutofillOptionsComponent);
component = fixture.componentInstance;
});
afterEach(() => {
jest.clearAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("registers 'autoFillOptionsForm' form with CipherFormContainer", () => {
fixture.detectChanges();
expect(cipherFormContainer.registerChildForm).toHaveBeenCalledWith(
"autoFillOptions",
component.autofillOptionsForm,
);
});
it("patches 'autoFillOptionsForm' changes to CipherFormContainer", () => {
fixture.detectChanges();
component.autofillOptionsForm.patchValue({
uris: [{ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact }],
autofillOnPageLoad: true,
});
expect(cipherFormContainer.patchCipher).toHaveBeenCalled();
const patchFn = cipherFormContainer.patchCipher.mock.lastCall[0];
const updatedCipher = patchFn(new CipherView());
const expectedUri = Object.assign(new LoginUriView(), {
uri: "https://example.com",
match: UriMatchStrategy.Exact,
} as LoginUriView);
expect(updatedCipher.login.uris).toEqual([expectedUri]);
expect(updatedCipher.login.autofillOnPageLoad).toEqual(true);
});
it("disables 'autoFillOptionsForm' when in partial-edit mode", () => {
cipherFormContainer.config.mode = "partial-edit";
fixture.detectChanges();
expect(component.autofillOptionsForm.disabled).toBe(true);
});
it("initializes 'autoFillOptionsForm' with original login view values", () => {
const existingLogin = new LoginUriView();
existingLogin.uri = "https://example.com";
existingLogin.match = UriMatchStrategy.Exact;
(cipherFormContainer.originalCipherView as CipherView) = new CipherView();
cipherFormContainer.originalCipherView.login = {
autofillOnPageLoad: true,
uris: [existingLogin],
} as LoginView;
fixture.detectChanges();
expect(component.autofillOptionsForm.value.uris).toEqual([
{ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact },
]);
expect(component.autofillOptionsForm.value.autofillOnPageLoad).toEqual(true);
});
it("initializes 'autoFillOptionsForm' with initialValues when creating a new cipher", () => {
cipherFormContainer.config.initialValues = { loginUri: "https://example.com" };
fixture.detectChanges();
expect(component.autofillOptionsForm.value.uris).toEqual([
{ uri: "https://example.com", matchDetection: null },
]);
expect(component.autofillOptionsForm.value.autofillOnPageLoad).toEqual(null);
});
it("initializes 'autoFillOptionsForm' with an empty URI when creating a new cipher", () => {
cipherFormContainer.config.initialValues = null;
fixture.detectChanges();
expect(component.autofillOptionsForm.value.uris).toEqual([{ uri: null, matchDetection: null }]);
});
it("updates the default autofill on page load label", () => {
fixture.detectChanges();
expect(component["autofillOptions"][0].label).toEqual("defaultLabel no");
(autofillSettingsService.autofillOnPageLoadDefault$ as BehaviorSubject<boolean>).next(true);
fixture.detectChanges();
expect(component["autofillOptions"][0].label).toEqual("defaultLabel yes");
});
it("announces the addition of a new URI input", fakeAsync(() => {
fixture.detectChanges();
// Mock the liveAnnouncer implementation so we can resolve it manually
let resolveAnnouncer: () => void;
jest.spyOn(liveAnnouncer, "announce").mockImplementation(
() =>
new Promise((resolve) => {
resolveAnnouncer = resolve;
}),
);
component.addUri(undefined, true);
fixture.detectChanges();
expect(liveAnnouncer.announce).toHaveBeenCalledWith("websiteAdded", "polite");
// Spy on the last URI input's focusInput method to ensure it is called
jest.spyOn(component["uriOptions"].last, "focusInput");
resolveAnnouncer(); // Resolve the liveAnnouncer promise so that focusOnNewInput$ pipe can continue
tick();
expect(component["uriOptions"].last.focusInput).toHaveBeenCalled();
}));
it("removes URI input when remove() is called", () => {
fixture.detectChanges();
// Add second Uri
component.addUri(undefined, true);
fixture.detectChanges();
// Remove first Uri
component.removeUri(0);
fixture.detectChanges();
expect(component.autofillOptionsForm.value.uris.length).toEqual(1);
});
});

View File

@ -0,0 +1,189 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { AsyncPipe, NgForOf, NgIf } from "@angular/common";
import { Component, OnInit, QueryList, ViewChildren } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { Subject, switchMap, take } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UriMatchStrategySetting } from "@bitwarden/common/models/domain/domain-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import {
CardComponent,
FormFieldModule,
IconButtonModule,
LinkModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
TypographyModule,
} from "@bitwarden/components";
import { CipherFormContainer } from "../../cipher-form-container";
import { UriOptionComponent } from "./uri-option.component";
interface UriField {
uri: string;
matchDetection: UriMatchStrategySetting;
}
@Component({
selector: "vault-autofill-options",
templateUrl: "./autofill-options.component.html",
standalone: true,
imports: [
SectionComponent,
SectionHeaderComponent,
TypographyModule,
JslibModule,
CardComponent,
ReactiveFormsModule,
NgForOf,
FormFieldModule,
SelectModule,
IconButtonModule,
UriOptionComponent,
LinkModule,
NgIf,
AsyncPipe,
],
})
export class AutofillOptionsComponent implements OnInit {
/**
* List of rendered UriOptionComponents. Used for focusing newly added Uri inputs.
*/
@ViewChildren(UriOptionComponent)
protected uriOptions: QueryList<UriOptionComponent>;
autofillOptionsForm = this.formBuilder.group({
uris: this.formBuilder.array<UriField>([]),
autofillOnPageLoad: [null as boolean],
});
protected get uriControls() {
return this.autofillOptionsForm.controls.uris.controls;
}
protected defaultMatchDetection$ = this.domainSettingsService.defaultUriMatchStrategy$;
protected autofillOptions: { label: string; value: boolean | null }[] = [
{ label: this.i18nService.t("default"), value: null },
{ label: this.i18nService.t("yes"), value: true },
{ label: this.i18nService.t("no"), value: false },
];
/**
* Emits when a new URI input is added to the form and should be focused.
*/
private focusOnNewInput$ = new Subject<void>();
constructor(
private cipherFormContainer: CipherFormContainer,
private formBuilder: FormBuilder,
private i18nService: I18nService,
private liveAnnouncer: LiveAnnouncer,
private domainSettingsService: DomainSettingsService,
private autofillSettingsService: AutofillSettingsServiceAbstraction,
) {
this.cipherFormContainer.registerChildForm("autoFillOptions", this.autofillOptionsForm);
this.autofillOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
this.cipherFormContainer.patchCipher((cipher) => {
cipher.login.uris = value.uris.map((uri: UriField) =>
Object.assign(new LoginUriView(), {
uri: uri.uri,
match: uri.matchDetection,
} as LoginUriView),
);
cipher.login.autofillOnPageLoad = value.autofillOnPageLoad;
return cipher;
});
});
this.updateDefaultAutofillLabel();
this.focusOnNewInput$
.pipe(
takeUntilDestroyed(),
// Wait for the new URI input to be added to the DOM
switchMap(() => this.uriOptions.changes.pipe(take(1))),
// Announce the new URI input before focusing it
switchMap(() => this.liveAnnouncer.announce(this.i18nService.t("websiteAdded"), "polite")),
)
.subscribe(() => {
this.uriOptions?.last?.focusInput();
});
}
ngOnInit() {
if (this.cipherFormContainer.originalCipherView?.login) {
this.initFromExistingCipher(this.cipherFormContainer.originalCipherView.login);
} else {
this.initNewCipher();
}
if (this.cipherFormContainer.config.mode === "partial-edit") {
this.autofillOptionsForm.disable();
}
}
private initFromExistingCipher(existingLogin: LoginView) {
existingLogin.uris?.forEach((uri) => {
this.addUri({
uri: uri.uri,
matchDetection: uri.match,
});
});
this.autofillOptionsForm.patchValue({
autofillOnPageLoad: existingLogin.autofillOnPageLoad,
});
}
private initNewCipher() {
this.addUri({
uri: this.cipherFormContainer.config.initialValues?.loginUri ?? null,
matchDetection: null,
});
this.autofillOptionsForm.patchValue({
autofillOnPageLoad: null,
});
}
private updateDefaultAutofillLabel() {
this.autofillSettingsService.autofillOnPageLoadDefault$
.pipe(takeUntilDestroyed())
.subscribe((value: boolean) => {
const defaultOption = this.autofillOptions.find((o) => o.value === value);
if (!defaultOption) {
return;
}
this.autofillOptions[0].label = this.i18nService.t("defaultLabel", defaultOption.label);
// Trigger change detection to update the label in the template
this.autofillOptions = [...this.autofillOptions];
});
}
/**
* Adds a new URI input to the form.
* @param uriFieldValue The initial value for the new URI input.
* @param focusNewInput If true, the new URI input will be focused after being added.
*/
addUri(uriFieldValue: UriField = { uri: null, matchDetection: null }, focusNewInput = false) {
this.autofillOptionsForm.controls.uris.push(this.formBuilder.control(uriFieldValue));
if (focusNewInput) {
this.focusOnNewInput$.next();
}
}
removeUri(i: number) {
this.autofillOptionsForm.controls.uris.removeAt(i);
}
}

View File

@ -0,0 +1,35 @@
<ng-container [formGroup]="uriForm">
<bit-form-field>
<bit-label>{{ "websiteUri" | i18n }}</bit-label>
<input bitInput formControlName="uri" #uriInput />
<button
type="button"
bitIconButton="bwi-cog"
bitSuffix
[appA11yTitle]="toggleTitle"
(click)="toggleMatchDetection()"
data-testid="toggle-match-detection-button"
></button>
<button
type="button"
bitIconButton="bwi-minus-circle"
buttonType="danger"
bitSuffix
[appA11yTitle]="'deleteWebsite' | i18n"
*ngIf="canRemove"
(click)="removeUri()"
data-testid="remove-uri-button"
></button>
</bit-form-field>
<bit-form-field *ngIf="showMatchDetection" class="!tw-mb-9 -tw-mt-4">
<bit-label>{{ "matchDetection" | i18n }}</bit-label>
<bit-select formControlName="matchDetection" #matchDetectionSelect>
<bit-option
*ngFor="let o of uriMatchOptions"
[label]="o.label"
[value]="o.value"
></bit-option>
</bit-select>
</bit-form-field>
</ng-container>

View File

@ -0,0 +1,143 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NG_VALUE_ACCESSOR } from "@angular/forms";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UriOptionComponent } from "./uri-option.component";
describe("UriOptionComponent", () => {
let component: UriOptionComponent;
let fixture: ComponentFixture<UriOptionComponent>;
const getToggleMatchDetectionBtn = () =>
fixture.nativeElement.querySelector(
"button[data-testid='toggle-match-detection-button']",
) as HTMLButtonElement;
const getMatchDetectionSelect = () =>
fixture.nativeElement.querySelector(
"bit-select[formControlName='matchDetection']",
) as HTMLSelectElement;
const getRemoveButton = () =>
fixture.nativeElement.querySelector(
"button[data-testid='remove-uri-button']",
) as HTMLButtonElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UriOptionComponent],
providers: [
{
provide: I18nService,
useValue: { t: (...keys: string[]) => keys.filter(Boolean).join(" ") },
},
],
}).compileComponents();
fixture = TestBed.createComponent(UriOptionComponent);
component = fixture.componentInstance;
// Ensure the component provides the NG_VALUE_ACCESSOR token
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR);
});
afterEach(() => {
jest.clearAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should update the default uri match strategy label", () => {
component.defaultMatchDetection = UriMatchStrategy.Exact;
fixture.detectChanges();
expect(component["uriMatchOptions"][0].label).toBe("defaultLabel exact");
component.defaultMatchDetection = UriMatchStrategy.StartsWith;
fixture.detectChanges();
expect(component["uriMatchOptions"][0].label).toBe("defaultLabel startsWith");
});
it("should focus the uri input when focusInput is called", () => {
fixture.detectChanges();
jest.spyOn(component["inputElement"].nativeElement, "focus");
component.focusInput();
expect(component["inputElement"].nativeElement.focus).toHaveBeenCalled();
});
it("should emit change and touch events when the control value changes", () => {
const changeFn = jest.fn();
const touchFn = jest.fn();
component.registerOnChange(changeFn);
component.registerOnTouched(touchFn);
fixture.detectChanges();
expect(changeFn).not.toHaveBeenCalled();
expect(touchFn).not.toHaveBeenCalled();
component["uriForm"].patchValue({ uri: "https://example.com" });
expect(changeFn).toHaveBeenCalled();
expect(touchFn).toHaveBeenCalled();
});
it("should disable the uri form when disabled state is set", () => {
fixture.detectChanges();
expect(component["uriForm"].enabled).toBe(true);
component.setDisabledState(true);
expect(component["uriForm"].enabled).toBe(false);
});
describe("match detection", () => {
it("should hide the match detection select by default", () => {
fixture.detectChanges();
expect(getMatchDetectionSelect()).toBeNull();
});
it("should show the match detection select when the toggle is clicked", () => {
fixture.detectChanges();
getToggleMatchDetectionBtn().click();
fixture.detectChanges();
expect(getMatchDetectionSelect()).not.toBeNull();
});
it("should update the match detection button title when the toggle is clicked", () => {
component.writeValue({ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact });
fixture.detectChanges();
expect(getToggleMatchDetectionBtn().title).toBe("showMatchDetection https://example.com");
getToggleMatchDetectionBtn().click();
fixture.detectChanges();
expect(getToggleMatchDetectionBtn().title).toBe("hideMatchDetection https://example.com");
});
});
describe("remove button", () => {
it("should show the remove button when canRemove is true", () => {
component.canRemove = true;
fixture.detectChanges();
expect(getRemoveButton()).toBeTruthy();
});
it("should hide the remove button when canRemove is false", () => {
component.canRemove = false;
fixture.detectChanges();
expect(getRemoveButton()).toBeFalsy();
});
it("should emit remove when the remove button is clicked", () => {
jest.spyOn(component.remove, "emit");
component.canRemove = true;
fixture.detectChanges();
getRemoveButton().click();
expect(component.remove.emit).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,164 @@
import { NgForOf, NgIf } from "@angular/common";
import {
Component,
ElementRef,
EventEmitter,
forwardRef,
Input,
Output,
ViewChild,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
ControlValueAccessor,
FormBuilder,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
UriMatchStrategy,
UriMatchStrategySetting,
} from "@bitwarden/common/models/domain/domain-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
FormFieldModule,
IconButtonModule,
SelectComponent,
SelectModule,
} from "@bitwarden/components";
@Component({
selector: "vault-autofill-uri-option",
templateUrl: "./uri-option.component.html",
standalone: true,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => UriOptionComponent),
multi: true,
},
],
imports: [
FormFieldModule,
ReactiveFormsModule,
IconButtonModule,
JslibModule,
SelectModule,
NgForOf,
NgIf,
],
})
export class UriOptionComponent implements ControlValueAccessor {
@ViewChild("uriInput")
private inputElement: ElementRef<HTMLInputElement>;
@ViewChild("matchDetectionSelect")
private matchDetectionSelect: SelectComponent<UriMatchStrategySetting>;
protected uriForm = this.formBuilder.group({
uri: [null as string],
matchDetection: [null as UriMatchStrategySetting],
});
protected uriMatchOptions: { label: string; value: UriMatchStrategySetting }[] = [
{ label: this.i18nService.t("default"), value: null },
{ label: this.i18nService.t("baseDomain"), value: UriMatchStrategy.Domain },
{ label: this.i18nService.t("host"), value: UriMatchStrategy.Host },
{ label: this.i18nService.t("startsWith"), value: UriMatchStrategy.StartsWith },
{ label: this.i18nService.t("regEx"), value: UriMatchStrategy.RegularExpression },
{ label: this.i18nService.t("exact"), value: UriMatchStrategy.Exact },
{ label: this.i18nService.t("never"), value: UriMatchStrategy.Never },
];
/**
* Whether the URI can be removed from the form. If false, the remove button will be hidden.
*/
@Input({ required: true })
canRemove: boolean;
/**
* The user's current default match detection strategy. Will be displayed in () after "Default"
*/
@Input({ required: true })
set defaultMatchDetection(value: UriMatchStrategySetting) {
this.uriMatchOptions[0].label = this.i18nService.t(
"defaultLabel",
this.uriMatchOptions.find((o) => o.value === value)?.label,
);
}
/**
* Emits when the remove button is clicked and URI should be removed from the form.
*/
@Output()
remove = new EventEmitter<void>();
protected showMatchDetection = false;
protected toggleMatchDetection() {
this.showMatchDetection = !this.showMatchDetection;
if (this.showMatchDetection) {
setTimeout(() => this.matchDetectionSelect?.select?.focus(), 0);
}
}
protected get toggleTitle() {
return this.showMatchDetection
? this.i18nService.t("hideMatchDetection", this.uriForm.value.uri)
: this.i18nService.t("showMatchDetection", this.uriForm.value.uri);
}
// NG_VALUE_ACCESSOR implementation
private onChange: any = () => {};
private onTouched: any = () => {};
constructor(
private formBuilder: FormBuilder,
private i18nService: I18nService,
) {
this.uriForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
this.onChange(value);
});
this.uriForm.statusChanges.pipe(takeUntilDestroyed()).subscribe(() => {
this.onTouched();
});
}
focusInput() {
if (this.inputElement?.nativeElement) {
this.inputElement.nativeElement.focus();
}
}
removeUri() {
this.remove.emit();
}
// NG_VALUE_ACCESSOR implementation
writeValue(value: any): void {
if (value) {
this.uriForm.setValue(
{
uri: value.uri ?? "",
matchDetection: value.match ?? null,
},
{ emitEvent: false },
);
}
}
registerOnChange(fn: () => void): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState?(isDisabled: boolean): void {
isDisabled ? this.uriForm.disable() : this.uriForm.enable();
}
}

View File

@ -190,7 +190,10 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
this.config.originalCipher, this.config.originalCipher,
); );
this.updatedCipherView = Object.assign(this.updatedCipherView, this.originalCipherView); // decryptCipher again to ensure we have a separate instance of CipherView
this.updatedCipherView = await this.addEditFormService.decryptCipher(
this.config.originalCipher,
);
if (this.config.mode === "clone") { if (this.config.mode === "clone") {
this.updatedCipherView.id = null; this.updatedCipherView.id = null;

View File

@ -108,3 +108,5 @@
</bit-form-field> </bit-form-field>
</bit-card> </bit-card>
</bit-section> </bit-section>
<vault-autofill-options></vault-autofill-options>

View File

@ -1,4 +1,5 @@
import { DatePipe } from "@angular/common"; import { DatePipe } from "@angular/common";
import { Component } from "@angular/core";
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
@ -12,9 +13,17 @@ import { ToastService } from "@bitwarden/components";
import { CipherFormGenerationService } from "../../abstractions/cipher-form-generation.service"; import { CipherFormGenerationService } from "../../abstractions/cipher-form-generation.service";
import { TotpCaptureService } from "../../abstractions/totp-capture.service"; import { TotpCaptureService } from "../../abstractions/totp-capture.service";
import { CipherFormContainer } from "../../cipher-form-container"; import { CipherFormContainer } from "../../cipher-form-container";
import { AutofillOptionsComponent } from "../autofill-options/autofill-options.component";
import { LoginDetailsSectionComponent } from "./login-details-section.component"; import { LoginDetailsSectionComponent } from "./login-details-section.component";
@Component({
standalone: true,
selector: "vault-autofill-options",
template: "",
})
class MockAutoFillOptionsComponent {}
describe("LoginDetailsSectionComponent", () => { describe("LoginDetailsSectionComponent", () => {
let component: LoginDetailsSectionComponent; let component: LoginDetailsSectionComponent;
let fixture: ComponentFixture<LoginDetailsSectionComponent>; let fixture: ComponentFixture<LoginDetailsSectionComponent>;
@ -45,7 +54,16 @@ describe("LoginDetailsSectionComponent", () => {
{ provide: TotpCaptureService, useValue: totpCaptureService }, { provide: TotpCaptureService, useValue: totpCaptureService },
{ provide: I18nService, useValue: i18nService }, { provide: I18nService, useValue: i18nService },
], ],
}).compileComponents(); })
.overrideComponent(LoginDetailsSectionComponent, {
remove: {
imports: [AutofillOptionsComponent],
},
add: {
imports: [MockAutoFillOptionsComponent],
},
})
.compileComponents();
fixture = TestBed.createComponent(LoginDetailsSectionComponent); fixture = TestBed.createComponent(LoginDetailsSectionComponent);
component = fixture.componentInstance; component = fixture.componentInstance;

View File

@ -24,6 +24,7 @@ import {
import { CipherFormGenerationService } from "../../abstractions/cipher-form-generation.service"; import { CipherFormGenerationService } from "../../abstractions/cipher-form-generation.service";
import { TotpCaptureService } from "../../abstractions/totp-capture.service"; import { TotpCaptureService } from "../../abstractions/totp-capture.service";
import { CipherFormContainer } from "../../cipher-form-container"; import { CipherFormContainer } from "../../cipher-form-container";
import { AutofillOptionsComponent } from "../autofill-options/autofill-options.component";
@Component({ @Component({
selector: "vault-login-details-section", selector: "vault-login-details-section",
@ -41,6 +42,7 @@ import { CipherFormContainer } from "../../cipher-form-container";
AsyncActionsModule, AsyncActionsModule,
NgIf, NgIf,
PopoverModule, PopoverModule,
AutofillOptionsComponent,
], ],
}) })
export class LoginDetailsSectionComponent implements OnInit { export class LoginDetailsSectionComponent implements OnInit {