mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-21 11:35:34 +01: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:
parent
ffc9022f54
commit
0d76835cd8
@ -3828,6 +3828,52 @@
|
||||
"authenticatorKey": {
|
||||
"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": {
|
||||
"message": "Card details"
|
||||
},
|
||||
|
@ -5,6 +5,7 @@ config.content = [
|
||||
"./src/**/*.{html,ts}",
|
||||
"../../libs/components/src/**/*.{html,ts}",
|
||||
"../../libs/auth/src/**/*.{html,ts}",
|
||||
"../../libs/vault/src/**/*.{html,ts}",
|
||||
"../../libs/angular/src/**/*.{html,ts}",
|
||||
"../../libs/vault/src/**/*.{html,ts}",
|
||||
];
|
||||
|
@ -48,6 +48,52 @@
|
||||
"authenticatorKey": {
|
||||
"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": {
|
||||
"message": "Number"
|
||||
},
|
||||
@ -8024,6 +8070,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"addField": {
|
||||
"message": "Add field"
|
||||
},
|
||||
"items": {
|
||||
"message": "Items"
|
||||
},
|
||||
|
@ -1,14 +1,17 @@
|
||||
import { Directive, ElementRef, Input, Renderer2 } from "@angular/core";
|
||||
import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core";
|
||||
|
||||
@Directive({
|
||||
selector: "[appA11yTitle]",
|
||||
})
|
||||
export class A11yTitleDirective {
|
||||
export class A11yTitleDirective implements OnInit {
|
||||
@Input() set appA11yTitle(title: string) {
|
||||
this.title = title;
|
||||
this.setAttributes();
|
||||
}
|
||||
|
||||
private title: string;
|
||||
private originalTitle: string | null;
|
||||
private originalAriaLabel: string | null;
|
||||
|
||||
constructor(
|
||||
private el: ElementRef,
|
||||
@ -16,10 +19,16 @@ export class A11yTitleDirective {
|
||||
) {}
|
||||
|
||||
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);
|
||||
}
|
||||
if (!this.el.nativeElement.hasAttribute("aria-label")) {
|
||||
if (this.originalAriaLabel === null) {
|
||||
this.renderer.setAttribute(this.el.nativeElement, "aria-label", this.title);
|
||||
}
|
||||
}
|
||||
|
@ -50,6 +50,9 @@ export class SelectComponent<T> implements BitFormFieldControl, ControlValueAcce
|
||||
|
||||
@ContentChildren(OptionComponent)
|
||||
protected set options(value: QueryList<OptionComponent<T>>) {
|
||||
if (value == null || value.length == 0) {
|
||||
return;
|
||||
}
|
||||
this.items = value.toArray();
|
||||
this.selectedOption = this.findSelectedOption(this.items, this.selectedValue);
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherFormConfig } from "@bitwarden/vault";
|
||||
|
||||
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 { CustomFieldsComponent } from "./components/custom-fields/custom-fields.component";
|
||||
import { IdentitySectionComponent } from "./components/identity/identity.component";
|
||||
@ -16,6 +17,7 @@ export type CipherForm = {
|
||||
itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"];
|
||||
additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"];
|
||||
loginDetails?: LoginDetailsSectionComponent["loginDetailsForm"];
|
||||
autoFillOptions?: AutofillOptionsComponent["autofillOptionsForm"];
|
||||
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
|
||||
identityDetails?: IdentitySectionComponent["identityForm"];
|
||||
customFields?: CustomFieldsComponent["customFieldsForm"];
|
||||
|
@ -11,6 +11,9 @@ import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
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 { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
@ -96,7 +99,7 @@ const defaultConfig: CipherFormConfig = {
|
||||
|
||||
class TestAddEditFormService implements CipherFormService {
|
||||
decryptCipher(): Promise<CipherView> {
|
||||
return Promise.resolve(defaultConfig.originalCipher as any);
|
||||
return Promise.resolve({ ...defaultConfig.originalCipher } as any);
|
||||
}
|
||||
async saveCipher(cipher: CipherView): Promise<CipherView> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
@ -151,6 +154,18 @@ export default {
|
||||
passwordLeaked: () => Promise.resolve(0),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainSettingsService,
|
||||
useValue: {
|
||||
defaultUriMatchStrategy$: new BehaviorSubject(UriMatchStrategy.StartsWith),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AutofillSettingsServiceAbstraction,
|
||||
useValue: {
|
||||
autofillOnPageLoadDefault$: new BehaviorSubject(true),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
componentWrapperDecorator(
|
||||
|
@ -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>
|
@ -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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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>
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
@ -190,7 +190,10 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
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") {
|
||||
this.updatedCipherView.id = null;
|
||||
|
@ -108,3 +108,5 @@
|
||||
</bit-form-field>
|
||||
</bit-card>
|
||||
</bit-section>
|
||||
|
||||
<vault-autofill-options></vault-autofill-options>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { DatePipe } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||
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 { TotpCaptureService } from "../../abstractions/totp-capture.service";
|
||||
import { CipherFormContainer } from "../../cipher-form-container";
|
||||
import { AutofillOptionsComponent } from "../autofill-options/autofill-options.component";
|
||||
|
||||
import { LoginDetailsSectionComponent } from "./login-details-section.component";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "vault-autofill-options",
|
||||
template: "",
|
||||
})
|
||||
class MockAutoFillOptionsComponent {}
|
||||
|
||||
describe("LoginDetailsSectionComponent", () => {
|
||||
let component: LoginDetailsSectionComponent;
|
||||
let fixture: ComponentFixture<LoginDetailsSectionComponent>;
|
||||
@ -45,7 +54,16 @@ describe("LoginDetailsSectionComponent", () => {
|
||||
{ provide: TotpCaptureService, useValue: totpCaptureService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
],
|
||||
}).compileComponents();
|
||||
})
|
||||
.overrideComponent(LoginDetailsSectionComponent, {
|
||||
remove: {
|
||||
imports: [AutofillOptionsComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockAutoFillOptionsComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(LoginDetailsSectionComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
@ -24,6 +24,7 @@ import {
|
||||
import { CipherFormGenerationService } from "../../abstractions/cipher-form-generation.service";
|
||||
import { TotpCaptureService } from "../../abstractions/totp-capture.service";
|
||||
import { CipherFormContainer } from "../../cipher-form-container";
|
||||
import { AutofillOptionsComponent } from "../autofill-options/autofill-options.component";
|
||||
|
||||
@Component({
|
||||
selector: "vault-login-details-section",
|
||||
@ -41,6 +42,7 @@ import { CipherFormContainer } from "../../cipher-form-container";
|
||||
AsyncActionsModule,
|
||||
NgIf,
|
||||
PopoverModule,
|
||||
AutofillOptionsComponent,
|
||||
],
|
||||
})
|
||||
export class LoginDetailsSectionComponent implements OnInit {
|
||||
|
Loading…
Reference in New Issue
Block a user