1
0
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:
Daniel Riera
2025-07-03 10:41:20 -04:00
committed by GitHub
parent ab4af7deed
commit 0311d0aaab
8 changed files with 253 additions and 4 deletions

View File

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

View File

@@ -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,
});
}
}

View File

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

View File

@@ -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);
});
});
});

View File

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