mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 13:23:34 +00:00
[PM-22391] display simple dialog when advanced matching strategy selected for login ciphers (#15260)
* PM-22391 WIP * update autofill base desc * fill cog when match uri open * switch to button, populate dialog when option selected * default strategy hint * update match hint string and dialog behavior * clean up naming for callbacks and variables * revert global setting hint — this will be addressed separately * add tests * update copy and remove repeated copy to use quoted string * Update apps/browser/src/_locales/en/messages.json Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> * add translation to web and desktop, make continue and cancel required --------- Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<bit-simple-dialog>
|
||||
<i
|
||||
bitDialogIcon
|
||||
class="bwi tw-text-3xl bwi-exclamation-triangle tw-text-warning"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span bitDialogTitle>
|
||||
{{ "warningCapitalized" | i18n }}
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<p>
|
||||
{{ contentKey | i18n }}
|
||||
<br />
|
||||
<button bitLink type="button" linkType="primary" (click)="openLink($event)">
|
||||
{{ "uriMatchWarningDialogLink" | i18n }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton type="button" buttonType="primary" (click)="onContinue()">
|
||||
{{ "continue" | i18n }}
|
||||
</button>
|
||||
<button bitButton type="button" buttonType="secondary" (click)="onCancel()">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Component, inject } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
ButtonLinkDirective,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
DIALOG_DATA,
|
||||
DialogRef,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export type AdvancedUriOptionDialogParams = {
|
||||
contentKey: string;
|
||||
onCancel: () => void;
|
||||
onContinue: () => void;
|
||||
};
|
||||
|
||||
@Component({
|
||||
templateUrl: "advanced-uri-option-dialog.component.html",
|
||||
imports: [ButtonLinkDirective, ButtonModule, DialogModule, JslibModule],
|
||||
})
|
||||
export class AdvancedUriOptionDialogComponent {
|
||||
constructor(private dialogRef: DialogRef<boolean>) {}
|
||||
|
||||
protected platformUtilsService = inject(PlatformUtilsService);
|
||||
protected params = inject<AdvancedUriOptionDialogParams>(DIALOG_DATA);
|
||||
|
||||
get contentKey(): string {
|
||||
return this.params.contentKey;
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.params.onCancel?.();
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
onContinue() {
|
||||
this.params.onContinue?.();
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
|
||||
openLink(event: Event) {
|
||||
event.preventDefault();
|
||||
this.platformUtilsService.launchUri("https://bitwarden.com/help/uri-match-detection/");
|
||||
}
|
||||
|
||||
static open(
|
||||
dialogService: DialogService,
|
||||
params: AdvancedUriOptionDialogParams,
|
||||
): DialogRef<boolean> {
|
||||
return dialogService.open<boolean>(AdvancedUriOptionDialogComponent, {
|
||||
data: params,
|
||||
disableClose: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
<input bitInput formControlName="uri" #uriInput />
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-cog"
|
||||
[bitIconButton]="showMatchDetection ? 'bwi-cog-f' : 'bwi-cog'"
|
||||
bitSuffix
|
||||
[appA11yTitle]="toggleTitle"
|
||||
(click)="toggleMatchDetection()"
|
||||
@@ -43,8 +43,16 @@
|
||||
*ngFor="let o of uriMatchOptions"
|
||||
[label]="o.label"
|
||||
[value]="o.value"
|
||||
[disabled]="o.disabled"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
<bit-hint *ngIf="getMatchHints() as hints">
|
||||
{{ hints[0] | i18n }}
|
||||
<ng-container *ngIf="hints.length > 1">
|
||||
<b>{{ "warningCapitalized" | i18n }}:</b>
|
||||
{{ hints[1] | i18n }}
|
||||
</ng-container>
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NG_VALUE_ACCESSOR } from "@angular/forms";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogRef, DialogService } from "@bitwarden/components";
|
||||
|
||||
import { AdvancedUriOptionDialogComponent } from "./advanced-uri-option-dialog.component";
|
||||
import { UriOptionComponent } from "./uri-option.component";
|
||||
|
||||
describe("UriOptionComponent", () => {
|
||||
let component: UriOptionComponent;
|
||||
let fixture: ComponentFixture<UriOptionComponent>;
|
||||
let dialogServiceMock: jest.Mocked<DialogService>;
|
||||
let dialogRefMock: jest.Mocked<DialogRef<boolean>>;
|
||||
|
||||
const getToggleMatchDetectionBtn = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
@@ -26,9 +31,19 @@ describe("UriOptionComponent", () => {
|
||||
) as HTMLButtonElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
dialogServiceMock = {
|
||||
open: jest.fn().mockReturnValue(dialogRefMock),
|
||||
} as unknown as jest.Mocked<DialogService>;
|
||||
|
||||
dialogRefMock = {
|
||||
close: jest.fn(),
|
||||
afterClosed: jest.fn().mockReturnValue(of(true)),
|
||||
} as unknown as jest.Mocked<DialogRef<boolean>>;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [UriOptionComponent],
|
||||
providers: [
|
||||
{ provide: DialogService, useValue: dialogServiceMock },
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: { t: (...keys: string[]) => keys.filter(Boolean).join(" ") },
|
||||
@@ -165,4 +180,36 @@ describe("UriOptionComponent", () => {
|
||||
expect(component.remove.emit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("advanced match strategy dialog", () => {
|
||||
function testDialogAction(action: "onContinue" | "onCancel", expected: number) {
|
||||
const openSpy = jest
|
||||
.spyOn(AdvancedUriOptionDialogComponent, "open")
|
||||
.mockReturnValue(dialogRefMock);
|
||||
|
||||
component["uriForm"].controls.matchDetection.setValue(UriMatchStrategy.Domain);
|
||||
component["uriForm"].controls.matchDetection.setValue(UriMatchStrategy.StartsWith);
|
||||
|
||||
const [, params] = openSpy.mock.calls[0] as [
|
||||
DialogService,
|
||||
{
|
||||
contentKey: string;
|
||||
onContinue: () => void;
|
||||
onCancel: () => void;
|
||||
},
|
||||
];
|
||||
|
||||
params[action]();
|
||||
|
||||
expect(component["uriForm"].value.matchDetection).toBe(expected);
|
||||
}
|
||||
|
||||
it("should apply the advanced match strategy when the user continues", () => {
|
||||
testDialogAction("onContinue", UriMatchStrategy.StartsWith);
|
||||
});
|
||||
|
||||
it("should revert to the previous strategy when the user cancels", () => {
|
||||
testDialogAction("onCancel", UriMatchStrategy.Domain);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
NG_VALUE_ACCESSOR,
|
||||
ReactiveFormsModule,
|
||||
} from "@angular/forms";
|
||||
import { concatMap, pairwise } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
@@ -26,12 +27,15 @@ import {
|
||||
} from "@bitwarden/common/models/domain/domain-service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
SelectComponent,
|
||||
SelectModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { AdvancedUriOptionDialogComponent } from "./advanced-uri-option-dialog.component";
|
||||
|
||||
@Component({
|
||||
selector: "vault-autofill-uri-option",
|
||||
templateUrl: "./uri-option.component.html",
|
||||
@@ -65,16 +69,26 @@ export class UriOptionComponent implements ControlValueAccessor {
|
||||
matchDetection: [null as UriMatchStrategySetting],
|
||||
});
|
||||
|
||||
protected uriMatchOptions: { label: string; value: UriMatchStrategySetting }[] = [
|
||||
protected uriMatchOptions: {
|
||||
label: string;
|
||||
value: UriMatchStrategySetting;
|
||||
disabled?: boolean;
|
||||
}[] = [
|
||||
{ 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 },
|
||||
{ label: this.i18nService.t("uriAdvancedOption"), value: null, disabled: true },
|
||||
{ label: this.i18nService.t("startsWith"), value: UriMatchStrategy.StartsWith },
|
||||
{ label: this.i18nService.t("regEx"), value: UriMatchStrategy.RegularExpression },
|
||||
];
|
||||
|
||||
protected advancedOptionWarningMap: Partial<Record<UriMatchStrategySetting, string>> = {
|
||||
[UriMatchStrategy.StartsWith]: "startsWithAdvancedOptionWarning",
|
||||
[UriMatchStrategy.RegularExpression]: "regExAdvancedOptionWarning",
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the option can be reordered. If false, the reorder button will be hidden.
|
||||
*/
|
||||
@@ -147,6 +161,7 @@ export class UriOptionComponent implements ControlValueAccessor {
|
||||
}
|
||||
|
||||
constructor(
|
||||
private dialogService: DialogService,
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
) {
|
||||
@@ -157,6 +172,36 @@ export class UriOptionComponent implements ControlValueAccessor {
|
||||
this.uriForm.statusChanges.pipe(takeUntilDestroyed()).subscribe(() => {
|
||||
this.onTouched();
|
||||
});
|
||||
|
||||
this.uriForm.controls.matchDetection.valueChanges
|
||||
.pipe(
|
||||
pairwise(),
|
||||
concatMap(([previous, current]) => this.handleAdvancedMatch(previous, current)),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private async handleAdvancedMatch(
|
||||
previous: UriMatchStrategySetting,
|
||||
current: UriMatchStrategySetting,
|
||||
) {
|
||||
const valueChange = previous !== current;
|
||||
const isAdvanced =
|
||||
current === UriMatchStrategy.StartsWith || current === UriMatchStrategy.RegularExpression;
|
||||
|
||||
if (!valueChange || !isAdvanced) {
|
||||
return;
|
||||
}
|
||||
AdvancedUriOptionDialogComponent.open(this.dialogService, {
|
||||
contentKey: this.advancedOptionWarningMap[current],
|
||||
onContinue: () => {
|
||||
this.uriForm.controls.matchDetection.setValue(current);
|
||||
},
|
||||
onCancel: () => {
|
||||
this.uriForm.controls.matchDetection.setValue(previous);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
focusInput() {
|
||||
@@ -193,4 +238,16 @@ export class UriOptionComponent implements ControlValueAccessor {
|
||||
setDisabledState?(isDisabled: boolean): void {
|
||||
isDisabled ? this.uriForm.disable() : this.uriForm.enable();
|
||||
}
|
||||
|
||||
getMatchHints() {
|
||||
const hints = ["uriMatchDefaultStrategyHint"];
|
||||
const strategy = this.uriForm.get("matchDetection")?.value;
|
||||
if (
|
||||
strategy === UriMatchStrategy.StartsWith ||
|
||||
strategy === UriMatchStrategy.RegularExpression
|
||||
) {
|
||||
hints.push(this.advancedOptionWarningMap[strategy]);
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user