mirror of
https://github.com/bitwarden/browser
synced 2026-01-29 07:43:28 +00:00
Merge branch 'main' into km/pm-27297
This commit is contained in:
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
@@ -8,7 +8,7 @@
|
||||
apps/desktop/desktop_native @bitwarden/team-platform-dev
|
||||
apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/desktop_native/macos_provider @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/desktop_native/autofill_provider @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-management-dev
|
||||
|
||||
## No ownership for Cargo.lock and Cargo.toml to allow dependency updates
|
||||
@@ -84,6 +84,7 @@ apps/web/src/app/billing @bitwarden/team-billing-dev
|
||||
libs/angular/src/billing @bitwarden/team-billing-dev
|
||||
libs/common/src/billing @bitwarden/team-billing-dev
|
||||
libs/billing @bitwarden/team-billing-dev
|
||||
libs/pricing @bitwarden/team-billing-dev
|
||||
bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev
|
||||
|
||||
## Platform team files ##
|
||||
@@ -227,7 +228,9 @@ apps/web/src/locales/en/messages.json
|
||||
**/tsconfig.json @bitwarden/team-platform-dev
|
||||
**/jest.config.js @bitwarden/team-platform-dev
|
||||
**/project.jsons @bitwarden/team-platform-dev
|
||||
libs/pricing @bitwarden/team-billing-dev
|
||||
# Platform override specifically for the package-lock.json in
|
||||
# native-messaging-test-runner so that Platform can manage all lock file updates
|
||||
apps/desktop/native-messaging-test-runner/package-lock.json @bitwarden/team-platform-dev
|
||||
|
||||
# Claude related files
|
||||
.claude/ @bitwarden/team-ai-sme
|
||||
|
||||
1
.github/renovate.json5
vendored
1
.github/renovate.json5
vendored
@@ -187,6 +187,7 @@
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"simplelog",
|
||||
"style-loader",
|
||||
"sysinfo",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"devFlags": {},
|
||||
"flags": {
|
||||
"accountSwitching": false,
|
||||
"sdk": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,5 @@
|
||||
"base": "https://localhost:8080"
|
||||
},
|
||||
"skipWelcomeOnInstall": true
|
||||
},
|
||||
"flags": {
|
||||
"accountSwitching": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"flags": {
|
||||
"accountSwitching": true
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,16 @@
|
||||
</popup-header>
|
||||
|
||||
<ng-container *ngIf="availableAccounts$ | async as availableAccounts">
|
||||
<bit-section [disableMargin]="!enableAccountSwitching">
|
||||
<bit-section [disableMargin]="!(enableAccountSwitching$ | async)">
|
||||
<ng-container *ngFor="let availableAccount of availableAccounts; first as isFirst">
|
||||
<div *ngIf="availableAccount.isActive" [ngClass]="{ 'tw-mb-6': enableAccountSwitching }">
|
||||
<div
|
||||
*ngIf="availableAccount.isActive"
|
||||
[ngClass]="{ 'tw-mb-6': enableAccountSwitching$ | async }"
|
||||
>
|
||||
<auth-account [account]="availableAccount" (loading)="loading = $event"></auth-account>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="enableAccountSwitching">
|
||||
<ng-container *ngIf="enableAccountSwitching$ | async">
|
||||
<bit-section-header *ngIf="isFirst">
|
||||
<h2 bitTypography="h6">{{ "availableAccounts" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CommonModule, Location } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { Subject, firstValueFrom, map, of, startWith, switchMap } from "rxjs";
|
||||
import { Observable, Subject, firstValueFrom, map, of, startWith, switchMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { LockService, LogoutService } from "@bitwarden/auth/common";
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { enableAccountSwitching } from "../../../platform/flags";
|
||||
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
|
||||
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
|
||||
@@ -59,7 +58,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
|
||||
|
||||
loading = false;
|
||||
activeUserCanLock = false;
|
||||
enableAccountSwitching = true;
|
||||
enableAccountSwitching$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
private accountSwitcherService: AccountSwitcherService,
|
||||
@@ -72,7 +71,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
|
||||
private authService: AuthService,
|
||||
private lockService: LockService,
|
||||
private logoutService: LogoutService,
|
||||
) {}
|
||||
) {
|
||||
this.enableAccountSwitching$ = this.accountSwitcherService.accountSwitchingEnabled$();
|
||||
}
|
||||
|
||||
get accountLimit() {
|
||||
return this.accountSwitcherService.ACCOUNT_LIMIT;
|
||||
@@ -97,19 +98,21 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
|
||||
switchMap((accounts) => {
|
||||
// If account switching is disabled, don't show the lock all button
|
||||
// as only one account should be shown.
|
||||
if (!enableAccountSwitching()) {
|
||||
return of(false);
|
||||
}
|
||||
return this.accountSwitcherService.accountSwitchingEnabled$().pipe(
|
||||
switchMap((enabled) => {
|
||||
if (!enabled) {
|
||||
return of(false);
|
||||
}
|
||||
|
||||
// When there are an inactive accounts provide the option to lock all accounts
|
||||
// Note: "Add account" is counted as an inactive account, so check for more than one account
|
||||
return of(accounts.length > 1);
|
||||
// When there are inactive accounts provide the option to lock all accounts
|
||||
// Note: "Add account" is counted as an inactive account, so check for more than one account
|
||||
return of(accounts.length > 1);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
async ngOnInit() {
|
||||
this.enableAccountSwitching = enableAccountSwitching();
|
||||
|
||||
const availableVaultTimeoutActions = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentService,
|
||||
@@ -37,6 +38,7 @@ describe("AccountSwitcherService", () => {
|
||||
const environmentService = mock<EnvironmentService>();
|
||||
const logService = mock<LogService>();
|
||||
const authService = mock<AuthService>();
|
||||
const configService = mock<ConfigService>();
|
||||
|
||||
let accountSwitcherService: AccountSwitcherService;
|
||||
|
||||
@@ -60,6 +62,7 @@ describe("AccountSwitcherService", () => {
|
||||
messagingService,
|
||||
environmentService,
|
||||
logService,
|
||||
configService,
|
||||
authService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
of,
|
||||
switchMap,
|
||||
throwError,
|
||||
timeout,
|
||||
@@ -17,11 +18,14 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { BrowserApi } from "../../../../platform/browser/browser-api";
|
||||
import { fromChromeEvent } from "../../../../platform/browser/from-chrome-event";
|
||||
|
||||
export type AvailableAccount = {
|
||||
@@ -52,6 +56,7 @@ export class AccountSwitcherService {
|
||||
private messagingService: MessagingService,
|
||||
private environmentService: EnvironmentService,
|
||||
private logService: LogService,
|
||||
private configService: ConfigService,
|
||||
authService: AuthService,
|
||||
) {
|
||||
this.availableAccounts$ = combineLatest([
|
||||
@@ -123,6 +128,19 @@ export class AccountSwitcherService {
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* PM-5594: This was a compile-time flag (default true) which made an exception for Safari in platform/flags.
|
||||
* The truthiness of AccountSwitching has been enshrined at this point, so those compile-time flags have been removed
|
||||
* in favor of this method to allow easier access to the config service for controlling Safari. Unwinding the Safari
|
||||
* flag should be more straightforward from this consolidation.
|
||||
*/
|
||||
accountSwitchingEnabled$(): Observable<boolean> {
|
||||
if (BrowserApi.isSafariApi) {
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.SafariAccountSwitching);
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
get specialAccountAddId() {
|
||||
return this.SPECIAL_ADD_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
pin: await this.pinService.isPinSet(activeAccount.id),
|
||||
pinLockWithMasterPassword:
|
||||
(await this.pinService.getPinLockType(activeAccount.id)) == "EPHEMERAL",
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(),
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(activeAccount.id),
|
||||
enableAutoBiometricsPrompt: await firstValueFrom(
|
||||
this.biometricStateService.promptAutomatically$,
|
||||
),
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
<div class="tw-bg-background-alt">
|
||||
<p>
|
||||
{{
|
||||
accountSwitcherEnabled ? ("excludedDomainsDescAlt" | i18n) : ("excludedDomainsDesc" | i18n)
|
||||
(accountSwitcherEnabled$ | async)
|
||||
? ("excludedDomainsDescAlt" | i18n)
|
||||
: ("excludedDomainsDesc" | i18n)
|
||||
}}
|
||||
</p>
|
||||
<bit-section *ngIf="!isLoading">
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
FormArray,
|
||||
} from "@angular/forms";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { Observable, Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { enableAccountSwitching } from "../../../platform/flags";
|
||||
import { AccountSwitcherService } from "../../../auth/popup/account-switching/services/account-switcher.service";
|
||||
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
|
||||
import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component";
|
||||
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
|
||||
@@ -74,7 +74,8 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy {
|
||||
@ViewChildren("uriInput") uriInputElements: QueryList<ElementRef<HTMLInputElement>> =
|
||||
new QueryList();
|
||||
|
||||
accountSwitcherEnabled = false;
|
||||
readonly accountSwitcherEnabled$: Observable<boolean> =
|
||||
this.accountSwitcherService.accountSwitchingEnabled$();
|
||||
dataIsPristine = true;
|
||||
isLoading = false;
|
||||
excludedDomainsState: string[] = [];
|
||||
@@ -95,9 +96,8 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy {
|
||||
private toastService: ToastService,
|
||||
private formBuilder: FormBuilder,
|
||||
private popupRouterCacheService: PopupRouterCacheService,
|
||||
) {
|
||||
this.accountSwitcherEnabled = enableAccountSwitching();
|
||||
}
|
||||
private accountSwitcherService: AccountSwitcherService,
|
||||
) {}
|
||||
|
||||
get domainForms() {
|
||||
return this.domainListForm.get("domains") as FormArray;
|
||||
|
||||
@@ -158,7 +158,7 @@ describe("CollectAutofillContentService", () => {
|
||||
type: "text",
|
||||
value: "",
|
||||
checked: false,
|
||||
autoCompleteType: "",
|
||||
autoCompleteType: null,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
selectInfo: null,
|
||||
@@ -346,7 +346,7 @@ describe("CollectAutofillContentService", () => {
|
||||
type: "text",
|
||||
value: "",
|
||||
checked: false,
|
||||
autoCompleteType: "",
|
||||
autoCompleteType: null,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
selectInfo: null,
|
||||
@@ -379,7 +379,7 @@ describe("CollectAutofillContentService", () => {
|
||||
type: "password",
|
||||
value: "",
|
||||
checked: false,
|
||||
autoCompleteType: "",
|
||||
autoCompleteType: null,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
selectInfo: null,
|
||||
@@ -588,7 +588,7 @@ describe("CollectAutofillContentService", () => {
|
||||
"aria-disabled": false,
|
||||
"aria-haspopup": false,
|
||||
"aria-hidden": false,
|
||||
autoCompleteType: "",
|
||||
autoCompleteType: null,
|
||||
checked: false,
|
||||
"data-stripe": null,
|
||||
disabled: false,
|
||||
@@ -621,7 +621,7 @@ describe("CollectAutofillContentService", () => {
|
||||
"aria-disabled": false,
|
||||
"aria-haspopup": false,
|
||||
"aria-hidden": false,
|
||||
autoCompleteType: "",
|
||||
autoCompleteType: null,
|
||||
checked: false,
|
||||
"data-stripe": null,
|
||||
disabled: false,
|
||||
@@ -2507,9 +2507,7 @@ describe("CollectAutofillContentService", () => {
|
||||
"class",
|
||||
"tabindex",
|
||||
"title",
|
||||
"value",
|
||||
"rel",
|
||||
"tagname",
|
||||
"checked",
|
||||
"disabled",
|
||||
"readonly",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { AUTOFILL_ATTRIBUTES } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
import AutofillField from "../models/autofill-field";
|
||||
import AutofillForm from "../models/autofill-form";
|
||||
import AutofillPageDetails from "../models/autofill-page-details";
|
||||
@@ -242,10 +244,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
this._autofillFormElements.set(formElement, {
|
||||
opid: formElement.opid,
|
||||
htmlAction: this.getFormActionAttribute(formElement),
|
||||
htmlName: this.getPropertyOrAttribute(formElement, "name"),
|
||||
htmlClass: this.getPropertyOrAttribute(formElement, "class"),
|
||||
htmlID: this.getPropertyOrAttribute(formElement, "id"),
|
||||
htmlMethod: this.getPropertyOrAttribute(formElement, "method"),
|
||||
htmlName: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.NAME),
|
||||
htmlClass: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.CLASS),
|
||||
htmlID: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.ID),
|
||||
htmlMethod: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.METHOD),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -260,7 +262,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
* @private
|
||||
*/
|
||||
private getFormActionAttribute(element: ElementWithOpId<HTMLFormElement>): string {
|
||||
return new URL(this.getPropertyOrAttribute(element, "action"), globalThis.location.href).href;
|
||||
return new URL(
|
||||
this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ACTION),
|
||||
globalThis.location.href,
|
||||
).href;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -335,7 +340,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
return priorityFormFields;
|
||||
}
|
||||
|
||||
const fieldType = this.getPropertyOrAttribute(element, "type")?.toLowerCase();
|
||||
const fieldType = this.getPropertyOrAttribute(
|
||||
element,
|
||||
AUTOFILL_ATTRIBUTES.TYPE,
|
||||
)?.toLowerCase();
|
||||
if (unimportantFieldTypesSet.has(fieldType)) {
|
||||
unimportantFormFields.push(element);
|
||||
continue;
|
||||
@@ -384,11 +392,11 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
elementNumber: index,
|
||||
maxLength: this.getAutofillFieldMaxLength(element),
|
||||
viewable: await this.domElementVisibilityService.isElementViewable(element),
|
||||
htmlID: this.getPropertyOrAttribute(element, "id"),
|
||||
htmlName: this.getPropertyOrAttribute(element, "name"),
|
||||
htmlClass: this.getPropertyOrAttribute(element, "class"),
|
||||
tabindex: this.getPropertyOrAttribute(element, "tabindex"),
|
||||
title: this.getPropertyOrAttribute(element, "title"),
|
||||
htmlID: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ID),
|
||||
htmlName: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.NAME),
|
||||
htmlClass: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.CLASS),
|
||||
tabindex: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.TABINDEX),
|
||||
title: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.TITLE),
|
||||
tagName: this.getAttributeLowerCase(element, "tagName"),
|
||||
dataSetValues: this.getDataSetValues(element),
|
||||
};
|
||||
@@ -404,16 +412,16 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
}
|
||||
|
||||
let autofillFieldLabels = {};
|
||||
const elementType = this.getAttributeLowerCase(element, "type");
|
||||
const elementType = this.getAttributeLowerCase(element, AUTOFILL_ATTRIBUTES.TYPE);
|
||||
if (elementType !== "hidden") {
|
||||
autofillFieldLabels = {
|
||||
"label-tag": this.createAutofillFieldLabelTag(element as FillableFormFieldElement),
|
||||
"label-data": this.getPropertyOrAttribute(element, "data-label"),
|
||||
"label-aria": this.getPropertyOrAttribute(element, "aria-label"),
|
||||
"label-data": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.DATA_LABEL),
|
||||
"label-aria": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ARIA_LABEL),
|
||||
"label-top": this.createAutofillFieldTopLabel(element),
|
||||
"label-right": this.createAutofillFieldRightLabel(element),
|
||||
"label-left": this.createAutofillFieldLeftLabel(element),
|
||||
placeholder: this.getPropertyOrAttribute(element, "placeholder"),
|
||||
placeholder: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.PLACEHOLDER),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -421,21 +429,21 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
const autofillField = {
|
||||
...autofillFieldBase,
|
||||
...autofillFieldLabels,
|
||||
rel: this.getPropertyOrAttribute(element, "rel"),
|
||||
rel: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.REL),
|
||||
type: elementType,
|
||||
value: this.getElementValue(element),
|
||||
checked: this.getAttributeBoolean(element, "checked"),
|
||||
checked: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.CHECKED),
|
||||
autoCompleteType: this.getAutoCompleteAttribute(element),
|
||||
disabled: this.getAttributeBoolean(element, "disabled"),
|
||||
readonly: this.getAttributeBoolean(element, "readonly"),
|
||||
disabled: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.DISABLED),
|
||||
readonly: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.READONLY),
|
||||
selectInfo: elementIsSelectElement(element)
|
||||
? this.getSelectElementOptions(element as HTMLSelectElement)
|
||||
: null,
|
||||
form: fieldFormElement ? this.getPropertyOrAttribute(fieldFormElement, "opid") : null,
|
||||
"aria-hidden": this.getAttributeBoolean(element, "aria-hidden", true),
|
||||
"aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true),
|
||||
"aria-haspopup": this.getAttributeBoolean(element, "aria-haspopup", true),
|
||||
"data-stripe": this.getPropertyOrAttribute(element, "data-stripe"),
|
||||
"aria-hidden": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_HIDDEN, true),
|
||||
"aria-disabled": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_DISABLED, true),
|
||||
"aria-haspopup": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_HASPOPUP, true),
|
||||
"data-stripe": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.DATA_STRIPE),
|
||||
};
|
||||
|
||||
this.cacheAutofillFieldElement(index, element, autofillField);
|
||||
@@ -467,9 +475,9 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
*/
|
||||
private getAutoCompleteAttribute(element: ElementWithOpId<FormFieldElement>): string {
|
||||
return (
|
||||
this.getPropertyOrAttribute(element, "x-autocompletetype") ||
|
||||
this.getPropertyOrAttribute(element, "autocompletetype") ||
|
||||
this.getPropertyOrAttribute(element, "autocomplete")
|
||||
this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.AUTOCOMPLETE) ||
|
||||
this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.X_AUTOCOMPLETE_TYPE) ||
|
||||
this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.AUTOCOMPLETE_TYPE)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -957,6 +965,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
this.mutationObserver = new MutationObserver(this.handleMutationObserverMutation);
|
||||
this.mutationObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
/** Mutations to node attributes NOT on this list will not be observed! */
|
||||
attributeFilter: Object.values(AUTOFILL_ATTRIBUTES),
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
@@ -1321,6 +1331,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
action: () => (dataTarget.htmlAction = this.getFormActionAttribute(element)),
|
||||
name: () => updateAttribute("htmlName"),
|
||||
id: () => updateAttribute("htmlID"),
|
||||
class: () => updateAttribute("htmlClass"),
|
||||
method: () => updateAttribute("htmlMethod"),
|
||||
};
|
||||
|
||||
@@ -1350,29 +1361,49 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey });
|
||||
};
|
||||
const updateActions: Record<string, CallableFunction> = {
|
||||
maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)),
|
||||
id: () => updateAttribute("htmlID"),
|
||||
name: () => updateAttribute("htmlName"),
|
||||
class: () => updateAttribute("htmlClass"),
|
||||
tabindex: () => updateAttribute("tabindex"),
|
||||
title: () => updateAttribute("tabindex"),
|
||||
rel: () => updateAttribute("rel"),
|
||||
tagname: () => (dataTarget.tagName = this.getAttributeLowerCase(element, "tagName")),
|
||||
type: () => (dataTarget.type = this.getAttributeLowerCase(element, "type")),
|
||||
value: () => (dataTarget.value = this.getElementValue(element)),
|
||||
checked: () => (dataTarget.checked = this.getAttributeBoolean(element, "checked")),
|
||||
disabled: () => (dataTarget.disabled = this.getAttributeBoolean(element, "disabled")),
|
||||
readonly: () => (dataTarget.readonly = this.getAttributeBoolean(element, "readonly")),
|
||||
autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)),
|
||||
"data-label": () => updateAttribute("label-data"),
|
||||
"aria-describedby": () => updateAttribute(AUTOFILL_ATTRIBUTES.ARIA_DESCRIBEDBY),
|
||||
"aria-label": () => updateAttribute("label-aria"),
|
||||
"aria-labelledby": () => updateAttribute(AUTOFILL_ATTRIBUTES.ARIA_LABELLEDBY),
|
||||
"aria-hidden": () =>
|
||||
(dataTarget["aria-hidden"] = this.getAttributeBoolean(element, "aria-hidden", true)),
|
||||
(dataTarget["aria-hidden"] = this.getAttributeBoolean(
|
||||
element,
|
||||
AUTOFILL_ATTRIBUTES.ARIA_HIDDEN,
|
||||
true,
|
||||
)),
|
||||
"aria-disabled": () =>
|
||||
(dataTarget["aria-disabled"] = this.getAttributeBoolean(element, "aria-disabled", true)),
|
||||
(dataTarget["aria-disabled"] = this.getAttributeBoolean(
|
||||
element,
|
||||
AUTOFILL_ATTRIBUTES.ARIA_DISABLED,
|
||||
true,
|
||||
)),
|
||||
"aria-haspopup": () =>
|
||||
(dataTarget["aria-haspopup"] = this.getAttributeBoolean(element, "aria-haspopup", true)),
|
||||
"data-stripe": () => updateAttribute("data-stripe"),
|
||||
(dataTarget["aria-haspopup"] = this.getAttributeBoolean(
|
||||
element,
|
||||
AUTOFILL_ATTRIBUTES.ARIA_HASPOPUP,
|
||||
true,
|
||||
)),
|
||||
autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)),
|
||||
autocompletetype: () =>
|
||||
(dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)),
|
||||
"x-autocompletetype": () =>
|
||||
(dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)),
|
||||
class: () => updateAttribute("htmlClass"),
|
||||
checked: () =>
|
||||
(dataTarget.checked = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.CHECKED)),
|
||||
"data-label": () => updateAttribute("label-data"),
|
||||
"data-stripe": () => updateAttribute(AUTOFILL_ATTRIBUTES.DATA_STRIPE),
|
||||
disabled: () =>
|
||||
(dataTarget.disabled = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.DISABLED)),
|
||||
id: () => updateAttribute("htmlID"),
|
||||
maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)),
|
||||
name: () => updateAttribute("htmlName"),
|
||||
placeholder: () => updateAttribute(AUTOFILL_ATTRIBUTES.PLACEHOLDER),
|
||||
readonly: () =>
|
||||
(dataTarget.readonly = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.READONLY)),
|
||||
rel: () => updateAttribute(AUTOFILL_ATTRIBUTES.REL),
|
||||
tabindex: () => updateAttribute(AUTOFILL_ATTRIBUTES.TABINDEX),
|
||||
title: () => updateAttribute(AUTOFILL_ATTRIBUTES.TITLE),
|
||||
type: () => (dataTarget.type = this.getAttributeLowerCase(element, AUTOFILL_ATTRIBUTES.TYPE)),
|
||||
};
|
||||
|
||||
if (!updateActions[attributeName]) {
|
||||
|
||||
@@ -7,6 +7,8 @@ export type PhishingResource = {
|
||||
todayUrl: string;
|
||||
/** Matcher used to decide whether a given URL matches an entry from this resource */
|
||||
match: (url: URL, entry: string) => boolean;
|
||||
/** Whether to use the custom matcher. If false, only exact hasUrl lookups are used. Default: true */
|
||||
useCustomMatcher?: boolean;
|
||||
};
|
||||
|
||||
export const PhishingResourceType = Object.freeze({
|
||||
@@ -56,6 +58,8 @@ export const PHISHING_RESOURCES: Record<PhishingResourceType, PhishingResource[]
|
||||
"https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-links-ACTIVE.txt.md5",
|
||||
todayUrl:
|
||||
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-links-NEW-today.txt",
|
||||
// Disabled for performance - cursor search takes 6+ minutes on large databases
|
||||
useCustomMatcher: false,
|
||||
match: (url: URL, entry: string) => {
|
||||
if (!entry) {
|
||||
return false;
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("PhishingDataService", () => {
|
||||
// Set default mock behaviors
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue([]);
|
||||
mockIndexedDbService.findMatchingUrl.mockResolvedValue(false);
|
||||
mockIndexedDbService.saveUrls.mockResolvedValue(undefined);
|
||||
mockIndexedDbService.addUrls.mockResolvedValue(undefined);
|
||||
mockIndexedDbService.saveUrlsFromStream.mockResolvedValue(undefined);
|
||||
@@ -90,7 +91,7 @@ describe("PhishingDataService", () => {
|
||||
|
||||
it("should NOT detect QA test addresses - different subpath", async () => {
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue([]);
|
||||
mockIndexedDbService.findMatchingUrl.mockResolvedValue(false);
|
||||
|
||||
const url = new URL("https://phishing.testcategory.com/other");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
@@ -120,70 +121,65 @@ describe("PhishingDataService", () => {
|
||||
expect(result).toBe(true);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/testing-param");
|
||||
// Should not fall back to custom matcher when hasUrl returns true
|
||||
expect(mockIndexedDbService.loadAllUrls).not.toHaveBeenCalled();
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fall back to custom matcher when hasUrl returns false", async () => {
|
||||
it("should return false when hasUrl returns false (custom matcher disabled)", async () => {
|
||||
// Mock hasUrl to return false (no direct href match)
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock loadAllUrls to return phishing URLs for custom matcher
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/path"]);
|
||||
|
||||
const url = new URL("http://phish.com/path");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Custom matcher is currently disabled (useCustomMatcher: false), so result is false
|
||||
expect(result).toBe(false);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/path");
|
||||
expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled();
|
||||
// Custom matcher should NOT be called since it's disabled
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not detect a safe web address", async () => {
|
||||
// Mock hasUrl to return false
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock loadAllUrls to return phishing URLs that don't match
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com", "http://badguy.net"]);
|
||||
|
||||
const url = new URL("http://safe.com");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://safe.com/");
|
||||
expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled();
|
||||
// Custom matcher is disabled, so findMatchingUrl should NOT be called
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not match against root web address with subpaths using custom matcher", async () => {
|
||||
it("should not match against root web address with subpaths (custom matcher disabled)", async () => {
|
||||
// Mock hasUrl to return false (no direct href match)
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock loadAllUrls to return entry that matches with subpath
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/login"]);
|
||||
|
||||
const url = new URL("http://phish.com/login/page");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page");
|
||||
expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled();
|
||||
// Custom matcher is disabled, so findMatchingUrl should NOT be called
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not match against root web address with different subpaths using custom matcher", async () => {
|
||||
it("should not match against root web address with different subpaths (custom matcher disabled)", async () => {
|
||||
// Mock hasUrl to return false (no direct hostname match)
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock loadAllUrls to return entry that matches with subpath
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/login/page1"]);
|
||||
|
||||
const url = new URL("http://phish.com/login/page2");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page2");
|
||||
expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled();
|
||||
// Custom matcher is disabled, so findMatchingUrl should NOT be called
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle IndexedDB errors gracefully", async () => {
|
||||
// Mock hasUrl to throw error
|
||||
mockIndexedDbService.hasUrl.mockRejectedValue(new Error("hasUrl error"));
|
||||
// Mock loadAllUrls to also throw error
|
||||
mockIndexedDbService.loadAllUrls.mockRejectedValue(new Error("IndexedDB error"));
|
||||
|
||||
const url = new URL("http://phish.com/about");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
@@ -193,10 +189,8 @@ describe("PhishingDataService", () => {
|
||||
"[PhishingDataService] IndexedDB lookup via hasUrl failed",
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingDataService] Error running custom matcher",
|
||||
expect.any(Error),
|
||||
);
|
||||
// Custom matcher is disabled, so no custom matcher error is expected
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -153,8 +153,18 @@ export class PhishingDataService {
|
||||
* @returns True if the URL is a known phishing web address, false otherwise
|
||||
*/
|
||||
async isPhishingWebAddress(url: URL): Promise<boolean> {
|
||||
this.logService.debug("[PhishingDataService] isPhishingWebAddress called for: " + url.href);
|
||||
|
||||
// Skip non-http(s) protocols - phishing database only contains web URLs
|
||||
// This prevents expensive fallback checks for chrome://, about:, file://, etc.
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
this.logService.debug("[PhishingDataService] Skipping non-http(s) protocol: " + url.protocol);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Quick check for QA/dev test addresses
|
||||
if (this._testWebAddresses.includes(url.href)) {
|
||||
this.logService.info("[PhishingDataService] Found test web address: " + url.href);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -162,28 +172,73 @@ export class PhishingDataService {
|
||||
|
||||
try {
|
||||
// Quick lookup: check direct presence of href in IndexedDB
|
||||
const hasUrl = await this.indexedDbService.hasUrl(url.href);
|
||||
// Also check without trailing slash since browsers add it but DB entries may not have it
|
||||
const urlHref = url.href;
|
||||
const urlWithoutTrailingSlash = urlHref.endsWith("/") ? urlHref.slice(0, -1) : null;
|
||||
|
||||
this.logService.debug("[PhishingDataService] Checking hasUrl on this string: " + urlHref);
|
||||
let hasUrl = await this.indexedDbService.hasUrl(urlHref);
|
||||
|
||||
// If not found and URL has trailing slash, try without it
|
||||
if (!hasUrl && urlWithoutTrailingSlash) {
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] Checking hasUrl without trailing slash: " +
|
||||
urlWithoutTrailingSlash,
|
||||
);
|
||||
hasUrl = await this.indexedDbService.hasUrl(urlWithoutTrailingSlash);
|
||||
}
|
||||
|
||||
if (hasUrl) {
|
||||
this.logService.info(
|
||||
"[PhishingDataService] Found phishing web address through direct lookup: " + urlHref,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] IndexedDB lookup via hasUrl failed", err);
|
||||
}
|
||||
|
||||
// If a custom matcher is provided, iterate stored entries and apply the matcher.
|
||||
if (resource && resource.match) {
|
||||
// If a custom matcher is provided and enabled, use cursor-based search.
|
||||
// This avoids loading all URLs into memory and allows early exit on first match.
|
||||
// Can be disabled via useCustomMatcher: false for performance reasons.
|
||||
if (resource && resource.match && resource.useCustomMatcher !== false) {
|
||||
try {
|
||||
const entries = await this.indexedDbService.loadAllUrls();
|
||||
for (const entry of entries) {
|
||||
if (resource.match(url, entry)) {
|
||||
return true;
|
||||
}
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] Starting cursor-based search for: " + url.href,
|
||||
);
|
||||
const startTime = performance.now();
|
||||
|
||||
const found = await this.indexedDbService.findMatchingUrl((entry) =>
|
||||
resource.match(url, entry),
|
||||
);
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = (endTime - startTime).toFixed(2);
|
||||
this.logService.debug(
|
||||
`[PhishingDataService] Cursor-based search completed in ${duration}ms for: ${url.href} (found: ${found})`,
|
||||
);
|
||||
|
||||
if (found) {
|
||||
this.logService.info(
|
||||
"[PhishingDataService] Found phishing web address through custom matcher: " + url.href,
|
||||
);
|
||||
} else {
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] No match found, returning false for: " + url.href,
|
||||
);
|
||||
}
|
||||
return found;
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] Error running custom matcher", err);
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] Returning false due to error for: " + url.href,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] No custom matcher, returning false for: " + url.href,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
@@ -43,6 +43,7 @@ export class PhishingDetectionService {
|
||||
private static _tabUpdated$ = new Subject<PhishingDetectionNavigationEvent>();
|
||||
private static _ignoredHostnames = new Set<string>();
|
||||
private static _didInit = false;
|
||||
private static _activeSearchCount = 0;
|
||||
|
||||
static initialize(
|
||||
logService: LogService,
|
||||
@@ -63,7 +64,7 @@ export class PhishingDetectionService {
|
||||
tap((message) =>
|
||||
logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`),
|
||||
),
|
||||
concatMap(async (message) => {
|
||||
mergeMap(async (message) => {
|
||||
const url = new URL(message.url);
|
||||
this._ignoredHostnames.add(url.hostname);
|
||||
await BrowserApi.navigateTabToUrl(message.tabId, url);
|
||||
@@ -88,23 +89,40 @@ export class PhishingDetectionService {
|
||||
prev.ignored === curr.ignored,
|
||||
),
|
||||
tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)),
|
||||
concatMap(async ({ tabId, url, ignored }) => {
|
||||
if (ignored) {
|
||||
// The next time this host is visited, block again
|
||||
this._ignoredHostnames.delete(url.hostname);
|
||||
return;
|
||||
}
|
||||
const isPhishing = await phishingDataService.isPhishingWebAddress(url);
|
||||
if (!isPhishing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phishingWarningPage = new URL(
|
||||
BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") +
|
||||
`?phishingUrl=${url.toString()}`,
|
||||
// Use mergeMap for parallel processing - each tab check runs independently
|
||||
// Concurrency limit of 5 prevents overwhelming IndexedDB
|
||||
mergeMap(async ({ tabId, url, ignored }) => {
|
||||
this._activeSearchCount++;
|
||||
const searchId = `${tabId}-${Date.now()}`;
|
||||
logService.debug(
|
||||
`[PhishingDetectionService] Search STARTED [${searchId}] for ${url.href} (active: ${this._activeSearchCount}/5)`,
|
||||
);
|
||||
await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage);
|
||||
}),
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
if (ignored) {
|
||||
// The next time this host is visited, block again
|
||||
this._ignoredHostnames.delete(url.hostname);
|
||||
return;
|
||||
}
|
||||
const isPhishing = await phishingDataService.isPhishingWebAddress(url);
|
||||
if (!isPhishing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phishingWarningPage = new URL(
|
||||
BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") +
|
||||
`?phishingUrl=${url.toString()}`,
|
||||
);
|
||||
await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage);
|
||||
} finally {
|
||||
this._activeSearchCount--;
|
||||
const duration = (performance.now() - startTime).toFixed(2);
|
||||
logService.debug(
|
||||
`[PhishingDetectionService] Search FINISHED [${searchId}] for ${url.href} in ${duration}ms (active: ${this._activeSearchCount}/5)`,
|
||||
);
|
||||
}
|
||||
}, 5),
|
||||
);
|
||||
|
||||
const onCancelCommand$ = messageListener
|
||||
|
||||
@@ -435,6 +435,89 @@ describe("PhishingIndexedDbService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMatchingUrl", () => {
|
||||
it("returns true when matcher finds a match", async () => {
|
||||
mockStore.set("https://example.com", { url: "https://example.com" });
|
||||
mockStore.set("https://phishing.net", { url: "https://phishing.net" });
|
||||
mockStore.set("https://test.org", { url: "https://test.org" });
|
||||
|
||||
const matcher = (url: string) => url.includes("phishing");
|
||||
const result = await service.findMatchingUrl(matcher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readonly");
|
||||
expect(mockObjectStore.openCursor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when no URLs match", async () => {
|
||||
mockStore.set("https://example.com", { url: "https://example.com" });
|
||||
mockStore.set("https://test.org", { url: "https://test.org" });
|
||||
|
||||
const matcher = (url: string) => url.includes("notfound");
|
||||
const result = await service.findMatchingUrl(matcher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when store is empty", async () => {
|
||||
const matcher = (url: string) => url.includes("anything");
|
||||
const result = await service.findMatchingUrl(matcher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("exits early on first match without iterating all records", async () => {
|
||||
mockStore.set("https://match1.com", { url: "https://match1.com" });
|
||||
mockStore.set("https://match2.com", { url: "https://match2.com" });
|
||||
mockStore.set("https://match3.com", { url: "https://match3.com" });
|
||||
|
||||
const matcherCallCount = jest
|
||||
.fn()
|
||||
.mockImplementation((url: string) => url.includes("match2"));
|
||||
await service.findMatchingUrl(matcherCallCount);
|
||||
|
||||
// Matcher should be called for match1.com and match2.com, but NOT match3.com
|
||||
// because it exits early on first match
|
||||
expect(matcherCallCount).toHaveBeenCalledWith("https://match1.com");
|
||||
expect(matcherCallCount).toHaveBeenCalledWith("https://match2.com");
|
||||
expect(matcherCallCount).not.toHaveBeenCalledWith("https://match3.com");
|
||||
expect(matcherCallCount).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("supports complex matcher logic", async () => {
|
||||
mockStore.set("https://example.com/path", { url: "https://example.com/path" });
|
||||
mockStore.set("https://test.org", { url: "https://test.org" });
|
||||
mockStore.set("https://phishing.net/login", { url: "https://phishing.net/login" });
|
||||
|
||||
const matcher = (url: string) => {
|
||||
return url.includes("phishing") && url.includes("login");
|
||||
};
|
||||
const result = await service.findMatchingUrl(matcher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on error", async () => {
|
||||
const error = new Error("IndexedDB error");
|
||||
mockOpenRequest.error = error;
|
||||
(global.indexedDB.open as jest.Mock).mockImplementation(() => {
|
||||
setTimeout(() => {
|
||||
mockOpenRequest.onerror?.();
|
||||
}, 0);
|
||||
return mockOpenRequest;
|
||||
});
|
||||
|
||||
const matcher = (url: string) => url.includes("test");
|
||||
const result = await service.findMatchingUrl(matcher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingIndexedDbService] Cursor search failed",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("database initialization", () => {
|
||||
it("creates object store with keyPath on upgrade", async () => {
|
||||
mockDb.objectStoreNames.contains.mockReturnValue(false);
|
||||
|
||||
@@ -195,6 +195,60 @@ export class PhishingIndexedDbService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any URL in the database matches the given matcher function.
|
||||
* Uses a cursor to iterate through records without loading all into memory.
|
||||
* Returns immediately on first match for optimal performance.
|
||||
*
|
||||
* @param matcher - Function that tests each URL and returns true if it matches
|
||||
* @returns `true` if any URL matches, `false` if none match or on error
|
||||
*/
|
||||
async findMatchingUrl(matcher: (url: string) => boolean): Promise<boolean> {
|
||||
this.logService.debug("[PhishingIndexedDbService] Searching for matching URL with cursor...");
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await this.openDatabase();
|
||||
return await this.cursorSearch(db, matcher);
|
||||
} catch (error) {
|
||||
this.logService.error("[PhishingIndexedDbService] Cursor search failed", error);
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs cursor-based search through all URLs.
|
||||
* Tests each URL with the matcher without accumulating records in memory.
|
||||
*/
|
||||
private cursorSearch(db: IDBDatabase, matcher: (url: string) => boolean): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db
|
||||
.transaction(this.STORE_NAME, "readonly")
|
||||
.objectStore(this.STORE_NAME)
|
||||
.openCursor();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = (e) => {
|
||||
const cursor = (e.target as IDBRequest<IDBCursorWithValue | null>).result;
|
||||
if (cursor) {
|
||||
const url = (cursor.value as PhishingUrlRecord).url;
|
||||
// Test the URL immediately without accumulating in memory
|
||||
if (matcher(url)) {
|
||||
// Found a match
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
// No match, continue to next record
|
||||
cursor.continue();
|
||||
} else {
|
||||
// Reached end of records without finding a match
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves phishing URLs directly from a stream.
|
||||
* Processes data incrementally to minimize memory usage.
|
||||
|
||||
@@ -35,7 +35,7 @@ export class BackgroundBrowserBiometricsService extends BiometricsService {
|
||||
super();
|
||||
// Always connect to the native messaging background if biometrics are enabled, not just when it is used
|
||||
// so that there is no wait when used.
|
||||
const biometricsEnabled = this.biometricStateService.biometricUnlockEnabled$;
|
||||
const biometricsEnabled = this.biometricStateService.biometricUnlockEnabled$();
|
||||
|
||||
combineLatest([timer(0, this.BACKGROUND_POLLING_INTERVAL), biometricsEnabled])
|
||||
.pipe(
|
||||
|
||||
@@ -375,7 +375,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
platformUtilsService.supportsSecureStorage.mockReturnValue(
|
||||
mockInputs.platformSupportsSecureStorage,
|
||||
);
|
||||
biometricStateService.biometricUnlockEnabled$ = of(true);
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true));
|
||||
|
||||
// PIN
|
||||
pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable);
|
||||
@@ -386,6 +386,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId));
|
||||
|
||||
expect(unlockOptions).toEqual(expectedOutput);
|
||||
expect(biometricStateService.biometricUnlockEnabled$).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ export class ExtensionLockComponentService implements LockComponentService {
|
||||
return combineLatest([
|
||||
// Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to
|
||||
defer(async () => {
|
||||
if (!(await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$))) {
|
||||
if (!(await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$(userId)))) {
|
||||
return BiometricsStatus.NotEnabledLocally;
|
||||
} else {
|
||||
// TODO remove after 2025.3
|
||||
|
||||
@@ -8,12 +8,8 @@ import {
|
||||
|
||||
import { GroupPolicyEnvironment } from "../admin-console/types/group-policy-environment";
|
||||
|
||||
import { BrowserApi } from "./browser/browser-api";
|
||||
|
||||
// required to avoid linting errors when there are no flags
|
||||
export type Flags = {
|
||||
accountSwitching?: boolean;
|
||||
} & SharedFlags;
|
||||
export type Flags = SharedFlags;
|
||||
|
||||
// required to avoid linting errors when there are no flags
|
||||
export type DevFlags = {
|
||||
@@ -31,14 +27,3 @@ export function devFlagEnabled(flag: keyof DevFlags) {
|
||||
export function devFlagValue(flag: keyof DevFlags) {
|
||||
return baseDevFlagValue(flag);
|
||||
}
|
||||
|
||||
/** Helper method to sync flag specifically for account switching, which as platform-based values.
|
||||
* If this pattern needs to be repeated, it's better handled by increasing complexity of webpack configurations
|
||||
* Not by expanding these flag getters.
|
||||
*/
|
||||
export function enableAccountSwitching(): boolean {
|
||||
if (BrowserApi.isSafariApi) {
|
||||
return false;
|
||||
}
|
||||
return flagEnabled("accountSwitching");
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
[showRefresh]="showRefresh"
|
||||
(onRefresh)="refreshCurrentTab()"
|
||||
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined"
|
||||
showAutofillButton
|
||||
isAutofillList
|
||||
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
|
||||
[primaryActionAutofill]="clickItemsToAutofillVaultView$ | async"
|
||||
[groupByType]="groupByType()"
|
||||
></app-vault-list-items-container>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { combineLatest, map, Observable, startWith } from "rxjs";
|
||||
import { combineLatest, map, Observable } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||
@@ -42,12 +42,6 @@ export class AutofillVaultListItemsComponent {
|
||||
*/
|
||||
protected showRefresh: boolean = BrowserPopupUtils.inSidebar(window);
|
||||
|
||||
/** Flag indicating whether the login item should automatically autofill when clicked */
|
||||
protected clickItemsToAutofillVaultView$: Observable<boolean> =
|
||||
this.vaultSettingsService.clickItemsToAutofillVaultView$.pipe(
|
||||
startWith(true), // Start with true to avoid flashing the fill button on first load
|
||||
);
|
||||
|
||||
protected readonly groupByType = toSignal(
|
||||
this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)),
|
||||
);
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
></button>
|
||||
<bit-menu #moreOptions>
|
||||
@if (!decryptionFailure) {
|
||||
<ng-container *ngIf="canAutofill && !hideAutofillOptions">
|
||||
<ng-container *ngIf="canAutofill && showAutofill()">
|
||||
<ng-container *ngIf="autofillAllowed$ | async">
|
||||
<button type="button" bitMenuItem (click)="doAutofill()">
|
||||
{{ "autofill" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="showViewOption">
|
||||
<ng-container>
|
||||
<button type="button" bitMenuItem (click)="onView()">
|
||||
{{ "view" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { booleanAttribute, Component, Input } from "@angular/core";
|
||||
import { booleanAttribute, Component, input, Input } from "@angular/core";
|
||||
import { Router, RouterModule } from "@angular/router";
|
||||
import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
|
||||
import { filter } from "rxjs/operators";
|
||||
@@ -76,22 +76,10 @@ export class ItemMoreOptionsComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag to show view item menu option. Used when something else is
|
||||
* assigned as the primary action for the item, such as autofill.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({ transform: booleanAttribute })
|
||||
showViewOption = false;
|
||||
|
||||
/**
|
||||
* Flag to hide the autofill menu options. Used for items that are
|
||||
* Flag to show the autofill menu options. Used for items that are
|
||||
* already in the autofill list suggestion.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({ transform: booleanAttribute })
|
||||
hideAutofillOptions = false;
|
||||
readonly showAutofill = input(false, { transform: booleanAttribute });
|
||||
|
||||
protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;
|
||||
|
||||
|
||||
@@ -90,11 +90,11 @@
|
||||
</ng-container>
|
||||
|
||||
<cdk-virtual-scroll-viewport [itemSize]="itemHeight$ | async" bitScrollLayout>
|
||||
<bit-item *cdkVirtualFor="let cipher of group.ciphers">
|
||||
<bit-item *cdkVirtualFor="let cipher of group.ciphers" class="tw-group/vault-item">
|
||||
<button
|
||||
bit-item-content
|
||||
type="button"
|
||||
(click)="primaryActionOnSelect(cipher)"
|
||||
(click)="onCipherSelect(cipher)"
|
||||
(dblclick)="launchCipher(cipher)"
|
||||
[appA11yTitle]="
|
||||
cipherItemTitleKey()(cipher)
|
||||
@@ -125,19 +125,14 @@
|
||||
</button>
|
||||
|
||||
<ng-container slot="end">
|
||||
<bit-item-action *ngIf="!hideAutofillButton()">
|
||||
<button
|
||||
type="button"
|
||||
bitBadge
|
||||
variant="primary"
|
||||
(click)="doAutofill(cipher)"
|
||||
[title]="autofillShortcutTooltip() ?? ('autofillTitle' | i18n: cipher.name)"
|
||||
[attr.aria-label]="'autofillTitle' | i18n: cipher.name"
|
||||
<bit-item-action *ngIf="isAutofillList()">
|
||||
<span
|
||||
class="tw-opacity-0 tw-text-sm tw-text-primary-600 tw-px-2 group-hover/vault-item:tw-opacity-100 group-focus-within/vault-item:tw-opacity-100"
|
||||
>
|
||||
{{ "fill" | i18n }}
|
||||
</button>
|
||||
</span>
|
||||
</bit-item-action>
|
||||
<bit-item-action *ngIf="!showAutofillButton() && CipherViewLikeUtils.canLaunch(cipher)">
|
||||
<bit-item-action *ngIf="!isAutofillList() && CipherViewLikeUtils.canLaunch(cipher)">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-external-link"
|
||||
@@ -149,8 +144,7 @@
|
||||
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
|
||||
<app-item-more-options
|
||||
[cipher]="cipher"
|
||||
[hideAutofillOptions]="hideAutofillMenuOptions()"
|
||||
[showViewOption]="primaryActionAutofill()"
|
||||
[showAutofill]="!isAutofillList()"
|
||||
></app-item-more-options>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
|
||||
@@ -136,24 +136,18 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
|
||||
*/
|
||||
private viewCipherTimeout?: number;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
ciphers = input<PopupCipherViewLike[]>([]);
|
||||
readonly ciphers = input<PopupCipherViewLike[]>([]);
|
||||
|
||||
/**
|
||||
* If true, we will group ciphers by type (Login, Card, Identity)
|
||||
* within subheadings in a single container, converted to a WritableSignal.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
groupByType = input<boolean | undefined>(false);
|
||||
readonly groupByType = input<boolean | undefined>(false);
|
||||
|
||||
/**
|
||||
* Computed signal for a grouped list of ciphers with an optional header
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
cipherGroups = computed<
|
||||
readonly cipherGroups = computed<
|
||||
{
|
||||
subHeaderKey?: string;
|
||||
ciphers: PopupCipherViewLike[];
|
||||
@@ -195,9 +189,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
|
||||
/**
|
||||
* Title for the vault list item section.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
title = input<string | undefined>(undefined);
|
||||
readonly title = input<string | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Optionally allow the items to be collapsed.
|
||||
@@ -205,24 +197,20 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
|
||||
* The key must be added to the state definition in `vault-popup-section.service.ts` since the
|
||||
* collapsed state is stored locally.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
|
||||
readonly collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Optional description for the vault list item section. Will be shown below the title even when
|
||||
* no ciphers are available.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
description = input<string | undefined>(undefined);
|
||||
|
||||
readonly description = input<string | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Option to show a refresh button in the section header.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
showRefresh = input(false, { transform: booleanAttribute });
|
||||
|
||||
readonly showRefresh = input(false, { transform: booleanAttribute });
|
||||
|
||||
/**
|
||||
* Event emitted when the refresh button is clicked.
|
||||
@@ -235,23 +223,16 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
|
||||
/**
|
||||
* Flag indicating that the current tab location is blocked
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
|
||||
readonly currentUriIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
|
||||
|
||||
/**
|
||||
* Resolved i18n key to use for suggested cipher items
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
cipherItemTitleKey = computed(() => {
|
||||
readonly cipherItemTitleKey = computed(() => {
|
||||
return (cipher: CipherViewLike) => {
|
||||
const login = CipherViewLikeUtils.getLogin(cipher);
|
||||
const hasUsername = login?.username != null;
|
||||
const key =
|
||||
this.primaryActionAutofill() && !this.currentURIIsBlocked()
|
||||
? "autofillTitle"
|
||||
: "viewItemTitle";
|
||||
const key = !this.currentUriIsBlocked() ? "autofillTitle" : "viewItemTitle";
|
||||
return hasUsername ? `${key}WithField` : key;
|
||||
};
|
||||
});
|
||||
@@ -259,47 +240,25 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
|
||||
/**
|
||||
* Option to show the autofill button for each item.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
showAutofillButton = input(false, { transform: booleanAttribute });
|
||||
readonly isAutofillList = input(false, { transform: booleanAttribute });
|
||||
|
||||
/**
|
||||
* Flag indicating whether the suggested cipher item autofill button should be shown or not
|
||||
* Computed property whether the cipher select action should perform autofill
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
hideAutofillButton = computed(
|
||||
() => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(),
|
||||
readonly shouldAutofillOnSelect = computed(
|
||||
() => this.isAutofillList() && !this.currentUriIsBlocked(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Flag indicating whether the cipher item autofill menu options should be shown or not
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton());
|
||||
|
||||
/**
|
||||
* Option to perform autofill operation as the primary action for autofill suggestions.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
primaryActionAutofill = input(false, { transform: booleanAttribute });
|
||||
|
||||
/**
|
||||
* Remove the bottom margin from the bit-section in this component
|
||||
* (used for containers at the end of the page where bottom margin is not needed)
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
disableSectionMargin = input(false, { transform: booleanAttribute });
|
||||
readonly disableSectionMargin = input(false, { transform: booleanAttribute });
|
||||
|
||||
/**
|
||||
* Remove the description margin
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
disableDescriptionMargin = input(false, { transform: booleanAttribute });
|
||||
readonly disableDescriptionMargin = input(false, { transform: booleanAttribute });
|
||||
|
||||
/**
|
||||
* The tooltip text for the organization icon for ciphers that belong to an organization.
|
||||
@@ -313,9 +272,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
|
||||
return collections[0]?.name;
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
protected autofillShortcutTooltip = signal<string | undefined>(undefined);
|
||||
protected readonly autofillShortcutTooltip = signal<string | undefined>(undefined);
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
@@ -340,10 +297,8 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
primaryActionOnSelect(cipher: PopupCipherViewLike) {
|
||||
return this.primaryActionAutofill() && !this.currentURIIsBlocked()
|
||||
? this.doAutofill(cipher)
|
||||
: this.onViewCipher(cipher);
|
||||
onCipherSelect(cipher: PopupCipherViewLike) {
|
||||
return this.shouldAutofillOnSelect() ? this.doAutofill(cipher) : this.onViewCipher(cipher);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,16 +50,10 @@
|
||||
<vault-permit-cipher-details-popover></vault-permit-cipher-details-popover>
|
||||
</bit-label>
|
||||
</bit-form-control>
|
||||
<bit-form-control>
|
||||
<bit-form-control disableMargin>
|
||||
<input bitCheckbox formControlName="showQuickCopyActions" type="checkbox" />
|
||||
<bit-label>{{ "showQuickCopyActions" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
<bit-form-control disableMargin>
|
||||
<input bitCheckbox formControlName="clickItemsToAutofillVaultView" type="checkbox" />
|
||||
<bit-label>
|
||||
{{ "clickToAutofill" | i18n }}
|
||||
</bit-label>
|
||||
</bit-form-control>
|
||||
</bit-card>
|
||||
</form>
|
||||
</popup-page>
|
||||
|
||||
@@ -59,14 +59,12 @@ describe("AppearanceV2Component", () => {
|
||||
const enableRoutingAnimation$ = new BehaviorSubject<boolean>(true);
|
||||
const enableCompactMode$ = new BehaviorSubject<boolean>(false);
|
||||
const showQuickCopyActions$ = new BehaviorSubject<boolean>(false);
|
||||
const clickItemsToAutofillVaultView$ = new BehaviorSubject<boolean>(false);
|
||||
const setSelectedTheme = jest.fn().mockResolvedValue(undefined);
|
||||
const setShowFavicons = jest.fn().mockResolvedValue(undefined);
|
||||
const setEnableBadgeCounter = jest.fn().mockResolvedValue(undefined);
|
||||
const setEnableRoutingAnimation = jest.fn().mockResolvedValue(undefined);
|
||||
const setEnableCompactMode = jest.fn().mockResolvedValue(undefined);
|
||||
const setShowQuickCopyActions = jest.fn().mockResolvedValue(undefined);
|
||||
const setClickItemsToAutofillVaultView = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const mockWidthService: Partial<PopupSizeService> = {
|
||||
width$: new BehaviorSubject("default"),
|
||||
@@ -113,10 +111,7 @@ describe("AppearanceV2Component", () => {
|
||||
},
|
||||
{
|
||||
provide: VaultSettingsService,
|
||||
useValue: {
|
||||
clickItemsToAutofillVaultView$,
|
||||
setClickItemsToAutofillVaultView,
|
||||
},
|
||||
useValue: mock<VaultSettingsService>(),
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -147,7 +142,6 @@ describe("AppearanceV2Component", () => {
|
||||
enableCompactMode: false,
|
||||
showQuickCopyActions: false,
|
||||
width: "default",
|
||||
clickItemsToAutofillVaultView: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,11 +187,5 @@ describe("AppearanceV2Component", () => {
|
||||
|
||||
expect(mockWidthService.setWidth).toHaveBeenCalledWith("wide");
|
||||
});
|
||||
|
||||
it("updates the click items to autofill vault view setting", () => {
|
||||
component.appearanceForm.controls.clickItemsToAutofillVaultView.setValue(true);
|
||||
|
||||
expect(setClickItemsToAutofillVaultView).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,6 @@ export class AppearanceV2Component implements OnInit {
|
||||
enableCompactMode: false,
|
||||
showQuickCopyActions: false,
|
||||
width: "default" as PopupWidthOption,
|
||||
clickItemsToAutofillVaultView: false,
|
||||
});
|
||||
|
||||
/** To avoid flashes of inaccurate values, only show the form after the entire form is populated. */
|
||||
@@ -112,9 +111,6 @@ export class AppearanceV2Component implements OnInit {
|
||||
this.copyButtonsService.showQuickCopyActions$,
|
||||
);
|
||||
const width = await firstValueFrom(this.popupSizeService.width$);
|
||||
const clickItemsToAutofillVaultView = await firstValueFrom(
|
||||
this.vaultSettingsService.clickItemsToAutofillVaultView$,
|
||||
);
|
||||
|
||||
// Set initial values for the form
|
||||
this.appearanceForm.setValue({
|
||||
@@ -125,7 +121,6 @@ export class AppearanceV2Component implements OnInit {
|
||||
enableCompactMode,
|
||||
showQuickCopyActions,
|
||||
width,
|
||||
clickItemsToAutofillVaultView,
|
||||
});
|
||||
|
||||
this.formLoading = false;
|
||||
@@ -171,16 +166,6 @@ export class AppearanceV2Component implements OnInit {
|
||||
.subscribe((width) => {
|
||||
void this.updateWidth(width);
|
||||
});
|
||||
|
||||
this.appearanceForm.controls.clickItemsToAutofillVaultView.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((clickItemsToAutofillVaultView) => {
|
||||
void this.updateClickItemsToAutofillVaultView(clickItemsToAutofillVaultView);
|
||||
});
|
||||
}
|
||||
|
||||
async updateClickItemsToAutofillVaultView(clickItemsToAutofillVaultView: boolean) {
|
||||
await this.vaultSettingsService.setClickItemsToAutofillVaultView(clickItemsToAutofillVaultView);
|
||||
}
|
||||
|
||||
async updateFavicon(enableFavicon: boolean) {
|
||||
|
||||
297
apps/desktop/desktop_native/Cargo.lock
generated
297
apps/desktop/desktop_native/Cargo.lock
generated
@@ -47,6 +47,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.18"
|
||||
@@ -324,6 +333,23 @@ version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
||||
|
||||
[[package]]
|
||||
name = "autofill_provider"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"desktop_core",
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-oslog",
|
||||
"tracing-subscriber",
|
||||
"uniffi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autotype"
|
||||
version = "0.0.0"
|
||||
@@ -603,6 +629,18 @@ dependencies = [
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -799,6 +837,41 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -810,6 +883,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "desktop_core"
|
||||
version = "0.0.0"
|
||||
@@ -988,6 +1071,12 @@ version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
@@ -1410,6 +1499,12 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.3"
|
||||
@@ -1425,7 +1520,7 @@ version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
"hashbrown 0.15.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1476,6 +1571,30 @@ dependencies = [
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.0.0"
|
||||
@@ -1562,6 +1681,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.0.3"
|
||||
@@ -1583,6 +1708,17 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.9.0"
|
||||
@@ -1590,7 +1726,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"hashbrown 0.15.3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1744,21 +1881,6 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "macos_provider"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"desktop_core",
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-oslog",
|
||||
"tracing-subscriber",
|
||||
"uniffi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -2017,6 +2139,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
@@ -2315,7 +2443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"indexmap",
|
||||
"indexmap 2.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2441,6 +2569,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -2622,6 +2756,26 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.9"
|
||||
@@ -2766,6 +2920,30 @@ dependencies = [
|
||||
"sdd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -2911,6 +3089,38 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.9.0",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.0",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test"
|
||||
version = "3.3.1"
|
||||
@@ -3231,6 +3441,37 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.1"
|
||||
@@ -3298,7 +3539,7 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.9.0",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.7.0",
|
||||
@@ -3328,7 +3569,7 @@ version = "0.22.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.9.0",
|
||||
"toml_datetime 0.6.9",
|
||||
"winnow",
|
||||
]
|
||||
@@ -3350,9 +3591,9 @@ checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
@@ -3361,9 +3602,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.28"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3372,9 +3613,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.33"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
@@ -3405,9 +3646,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.20"
|
||||
version = "0.3.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"autofill_provider",
|
||||
"autotype",
|
||||
"bitwarden_chromium_import_helper",
|
||||
"chromium_importer",
|
||||
"core",
|
||||
"macos_provider",
|
||||
"napi",
|
||||
"process_isolation",
|
||||
"proxy",
|
||||
@@ -58,6 +58,7 @@ security-framework = "=3.5.1"
|
||||
security-framework-sys = "=2.15.0"
|
||||
serde = "=1.0.209"
|
||||
serde_json = "=1.0.127"
|
||||
serde_with = "=3.14.1"
|
||||
sha2 = "=0.10.9"
|
||||
ssh-encoding = "=0.2.0"
|
||||
ssh-key = { version = "=0.6.7", default-features = false }
|
||||
@@ -65,8 +66,8 @@ sysinfo = "=0.37.2"
|
||||
thiserror = "=2.0.17"
|
||||
tokio = "=1.48.0"
|
||||
tokio-util = "=0.7.17"
|
||||
tracing = "=0.1.41"
|
||||
tracing-subscriber = { version = "=0.3.20", features = [
|
||||
tracing = "=0.1.44"
|
||||
tracing-subscriber = { version = "=0.3.22", features = [
|
||||
"fmt",
|
||||
"env-filter",
|
||||
"tracing-log",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "macos_provider"
|
||||
name = "autofill_provider"
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
version = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib", "cdylib"]
|
||||
crate-type = ["lib", "staticlib", "cdylib"]
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
@@ -14,17 +14,19 @@ name = "uniffi-bindgen"
|
||||
path = "uniffi-bindgen.rs"
|
||||
|
||||
[dependencies]
|
||||
uniffi = { workspace = true, features = ["cli"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
base64 = { workspace = true }
|
||||
desktop_core = { path = "../core" }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_with = { workspace = true, features = ["base64"] }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
tracing-oslog = "=0.3.0"
|
||||
tracing-subscriber = { workspace = true }
|
||||
uniffi = { workspace = true, features = ["cli"] }
|
||||
|
||||
[build-dependencies]
|
||||
uniffi = { workspace = true, features = ["build"] }
|
||||
@@ -1,3 +1,7 @@
|
||||
# Autofill Provider
|
||||
|
||||
A library for native autofill providers to interact with a host Bitwarden desktop app.
|
||||
|
||||
# Explainer: Mac OS Native Passkey Provider
|
||||
|
||||
This document describes the changes introduced in https://github.com/bitwarden/clients/pull/13963, where we introduce the MacOS Native Passkey Provider. It gives the high level explanation of the architecture and some of the quirks and additional good to know context.
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
rm -r BitwardenMacosProviderFFI.xcframework
|
||||
rm -r tmp
|
||||
if [ -d "BitwardenMacosProviderFFI.xcframework" ]; then
|
||||
rm -r "BitwardenMacosProviderFFI.xcframework"
|
||||
fi
|
||||
if [ -d "tmp" ]; then
|
||||
rm -r "tmp"
|
||||
fi
|
||||
|
||||
mkdir -p ./tmp/target/universal-darwin/release/
|
||||
|
||||
@@ -11,17 +15,17 @@ mkdir -p ./tmp/target/universal-darwin/release/
|
||||
rustup target add aarch64-apple-darwin
|
||||
rustup target add x86_64-apple-darwin
|
||||
|
||||
cargo build --package macos_provider --target aarch64-apple-darwin --release
|
||||
cargo build --package macos_provider --target x86_64-apple-darwin --release
|
||||
cargo build --package autofill_provider --target aarch64-apple-darwin --release
|
||||
cargo build --package autofill_provider --target x86_64-apple-darwin --release
|
||||
|
||||
# Create universal libraries
|
||||
lipo -create ../target/aarch64-apple-darwin/release/libmacos_provider.a \
|
||||
../target/x86_64-apple-darwin/release/libmacos_provider.a \
|
||||
-output ./tmp/target/universal-darwin/release/libmacos_provider.a
|
||||
lipo -create ../target/aarch64-apple-darwin/release/libautofill_provider.a \
|
||||
../target/x86_64-apple-darwin/release/libautofill_provider.a \
|
||||
-output ./tmp/target/universal-darwin/release/libautofill_provider.a
|
||||
|
||||
# Generate swift bindings
|
||||
cargo run --bin uniffi-bindgen --features uniffi/cli generate \
|
||||
../target/aarch64-apple-darwin/release/libmacos_provider.dylib \
|
||||
../target/aarch64-apple-darwin/release/libautofill_provider.dylib \
|
||||
--library \
|
||||
--language swift \
|
||||
--no-format \
|
||||
@@ -38,7 +42,7 @@ cat ./tmp/bindings/*.modulemap > ./tmp/Headers/module.modulemap
|
||||
|
||||
# Build xcframework
|
||||
xcodebuild -create-xcframework \
|
||||
-library ./tmp/target/universal-darwin/release/libmacos_provider.a \
|
||||
-library ./tmp/target/universal-darwin/release/libautofill_provider.a \
|
||||
-headers ./tmp/Headers \
|
||||
-output ./BitwardenMacosProviderFFI.xcframework
|
||||
|
||||
184
apps/desktop/desktop_native/autofill_provider/src/assertion.rs
Normal file
184
apps/desktop/desktop_native/autofill_provider/src/assertion.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use crate::TimedCallback;
|
||||
use crate::{BitwardenError, Callback, Position, UserVerification};
|
||||
|
||||
/// Request to assert a credential.
|
||||
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyAssertionRequest {
|
||||
/// Relying Party ID for the request.
|
||||
pub rp_id: String,
|
||||
|
||||
/// SHA-256 hash of the `clientDataJSON` for the assertion request.
|
||||
pub client_data_hash: Vec<u8>,
|
||||
|
||||
/// User verification preference.
|
||||
pub user_verification: UserVerification,
|
||||
|
||||
/// List of allowed credential IDs.
|
||||
pub allowed_credentials: Vec<Vec<u8>>,
|
||||
|
||||
/// Coordinates of the center of the WebAuthn client's window, relative to
|
||||
/// the top-left point on the screen.
|
||||
/// # Operating System Differences
|
||||
///
|
||||
/// ## macOS
|
||||
/// Note that macOS APIs gives points relative to the bottom-left point on the
|
||||
/// screen by default, so the y-coordinate will be flipped.
|
||||
///
|
||||
/// ## Windows
|
||||
/// On Windows, this must be logical pixels, not physical pixels.
|
||||
pub window_xy: Position,
|
||||
|
||||
/// Byte string representing the native OS window handle for the WebAuthn client.
|
||||
/// # Operating System Differences
|
||||
///
|
||||
/// ## macOS
|
||||
/// Unused.
|
||||
///
|
||||
/// ## Windows
|
||||
/// On Windows, this is a HWND.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub client_window_handle: Vec<u8>,
|
||||
|
||||
/// Native context required for callbacks to the OS. Format differs on the OS.
|
||||
/// # Operating System Differences
|
||||
///
|
||||
/// ## macOS
|
||||
/// Unused.
|
||||
///
|
||||
/// ## Windows
|
||||
/// On Windows, this is a base64-string representing the following data:
|
||||
/// `request transaction id (GUID, 16 bytes) || SHA-256(pluginOperationRequest)`
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub context: String,
|
||||
// TODO(PM-30510): Implement support for extensions
|
||||
// pub extension_input: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Request to assert a credential without user interaction.
|
||||
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
|
||||
/// Relying Party ID.
|
||||
pub rp_id: String,
|
||||
|
||||
/// The allowed credential ID for the request.
|
||||
pub credential_id: Vec<u8>,
|
||||
|
||||
/// The user name for the credential that was previously given to the OS.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub user_name: String,
|
||||
|
||||
/// The user ID for the credential that was previously given to the OS.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub user_handle: Vec<u8>,
|
||||
|
||||
/// The app-specific local identifier for the credential, in our case, the
|
||||
/// cipher ID.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub record_identifier: Option<String>,
|
||||
|
||||
/// SHA-256 hash of the `clientDataJSON` for the assertion request.
|
||||
pub client_data_hash: Vec<u8>,
|
||||
|
||||
/// User verification preference.
|
||||
pub user_verification: UserVerification,
|
||||
|
||||
/// Coordinates of the center of the WebAuthn client's window, relative to
|
||||
/// the top-left point on the screen.
|
||||
/// # Operating System Differences
|
||||
///
|
||||
/// ## macOS
|
||||
/// Note that macOS APIs gives points relative to the bottom-left point on the
|
||||
/// screen by default, so the y-coordinate will be flipped.
|
||||
///
|
||||
/// ## Windows
|
||||
/// On Windows, this must be logical pixels, not physical pixels.
|
||||
pub window_xy: Position,
|
||||
|
||||
/// Byte string representing the native OS window handle for the WebAuthn client.
|
||||
/// # Operating System Differences
|
||||
///
|
||||
/// ## macOS
|
||||
/// Unused.
|
||||
///
|
||||
/// ## Windows
|
||||
/// On Windows, this is a HWND.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub client_window_handle: Vec<u8>,
|
||||
|
||||
/// Native context required for callbacks to the OS. Format differs on the OS.
|
||||
/// # Operating System Differences
|
||||
///
|
||||
/// ## macOS
|
||||
/// Unused.
|
||||
///
|
||||
/// ## Windows
|
||||
/// On Windows, this is `request transaction id () || SHA-256(pluginOperationRequest)`.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub context: String,
|
||||
}
|
||||
|
||||
/// Response for a passkey assertion request.
|
||||
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyAssertionResponse {
|
||||
/// Relying Party ID.
|
||||
pub rp_id: String,
|
||||
|
||||
/// The user ID for the credential that was previously given to the OS.
|
||||
pub user_handle: Vec<u8>,
|
||||
|
||||
/// The signature for the WebAuthn attestation response.
|
||||
pub signature: Vec<u8>,
|
||||
|
||||
/// SHA-256 hash of the `clientDataJSON` used in the assertion.
|
||||
pub client_data_hash: Vec<u8>,
|
||||
|
||||
/// The WebAuthn authenticator data structure.
|
||||
pub authenticator_data: Vec<u8>,
|
||||
|
||||
/// The ID for the attested credential.
|
||||
pub credential_id: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Callback to process a response to passkey assertion request.
|
||||
#[cfg_attr(target_os = "macos", uniffi::export(with_foreign))]
|
||||
pub trait PreparePasskeyAssertionCallback: Send + Sync {
|
||||
/// Function to call if a successful response is returned.
|
||||
fn on_complete(&self, credential: PasskeyAssertionResponse);
|
||||
|
||||
/// Function to call if an error response is returned.
|
||||
fn on_error(&self, error: BitwardenError);
|
||||
}
|
||||
|
||||
impl Callback for Arc<dyn PreparePasskeyAssertionCallback> {
|
||||
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
|
||||
let credential = serde_json::from_value(credential)?;
|
||||
PreparePasskeyAssertionCallback::on_complete(self.as_ref(), credential);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn error(&self, error: BitwardenError) {
|
||||
PreparePasskeyAssertionCallback::on_error(self.as_ref(), error);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
impl PreparePasskeyAssertionCallback for TimedCallback<PasskeyAssertionResponse> {
|
||||
fn on_complete(&self, credential: PasskeyAssertionResponse) {
|
||||
self.send(Ok(credential));
|
||||
}
|
||||
|
||||
fn on_error(&self, error: BitwardenError) {
|
||||
self.send(Err(error));
|
||||
}
|
||||
}
|
||||
754
apps/desktop/desktop_native/autofill_provider/src/lib.rs
Normal file
754
apps/desktop/desktop_native/autofill_provider/src/lib.rs
Normal file
@@ -0,0 +1,754 @@
|
||||
#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation
|
||||
mod assertion;
|
||||
mod lock_status;
|
||||
mod registration;
|
||||
mod window_handle_query;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::sync::Once;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fmt::Display,
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::AtomicU32,
|
||||
mpsc::{self, Receiver, RecvTimeoutError, Sender},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub use assertion::{
|
||||
PasskeyAssertionRequest, PasskeyAssertionResponse, PasskeyAssertionWithoutUserInterfaceRequest,
|
||||
PreparePasskeyAssertionCallback,
|
||||
};
|
||||
use futures::FutureExt;
|
||||
pub use lock_status::LockStatusResponse;
|
||||
pub use registration::{
|
||||
PasskeyRegistrationRequest, PasskeyRegistrationResponse, PreparePasskeyRegistrationCallback,
|
||||
};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use tracing::{error, info};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tracing_subscriber::{
|
||||
filter::{EnvFilter, LevelFilter},
|
||||
layer::SubscriberExt,
|
||||
util::SubscriberInitExt,
|
||||
};
|
||||
pub use window_handle_query::WindowHandleQueryResponse;
|
||||
|
||||
use crate::{
|
||||
lock_status::{GetLockStatusCallback, LockStatusRequest},
|
||||
window_handle_query::{GetWindowHandleQueryCallback, WindowHandleQueryRequest},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
uniffi::setup_scaffolding!();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
/// User verification preference for WebAuthn requests.
|
||||
#[cfg_attr(target_os = "macos", derive(uniffi::Enum))]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UserVerification {
|
||||
Preferred,
|
||||
Required,
|
||||
Discouraged,
|
||||
}
|
||||
|
||||
/// Coordinates representing a point on the screen.
|
||||
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Position {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "macos", derive(uniffi::Error))]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum BitwardenError {
|
||||
Internal(String),
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
impl Display for BitwardenError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Internal(msg) => write!(f, "Internal error occurred: {msg}"),
|
||||
Self::Disconnected => {
|
||||
write!(f, "Client is disconnected from autofill IPC service")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for BitwardenError {}
|
||||
|
||||
// These methods are named differently than the actual Uniffi traits (without
|
||||
// the `on_` prefix) to avoid ambiguous trait implementations in the generated
|
||||
// code.
|
||||
trait Callback: Send + Sync {
|
||||
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error>;
|
||||
fn error(&self, error: BitwardenError);
|
||||
}
|
||||
|
||||
/// Store the connection status between the credential provider extension
|
||||
/// and the desktop application's IPC server.
|
||||
#[cfg_attr(target_os = "macos", derive(uniffi::Enum))]
|
||||
#[derive(Debug)]
|
||||
pub enum ConnectionStatus {
|
||||
Connected,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// A client to send and receive messages to the autofill service on the desktop
|
||||
/// client.
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// In order to accommodate desktop app startup delays and non-blocking
|
||||
/// requirements for native providers, this initialization of the client is
|
||||
/// non-blocking. When calling [`AutofillProviderClient::connect()`], the
|
||||
/// connection is not established immediately, but may be established later in
|
||||
/// the background or may fail to be established.
|
||||
///
|
||||
/// Before calling [`AutofillProviderClient::connect()`], first check whether
|
||||
/// the desktop app is running with [`AutofillProviderClient::is_available`],
|
||||
/// and attempt to start it if it is not running. Then, attempt to connect, retrying as necessary.
|
||||
/// Before calling any other methods, check the connection status using
|
||||
/// [`AutofillProviderClient::get_connection_status()`].
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use std::{sync::Arc, time::Duration};
|
||||
///
|
||||
/// use autofill_provider::{AutofillProviderClient, ConnectionStatus, TimedCallback};
|
||||
///
|
||||
/// fn establish_connection() -> Option<AutofillProviderClient> {
|
||||
/// if !AutofillProviderClient::is_available() {
|
||||
/// // Start application
|
||||
/// }
|
||||
/// let max_attempts = 20;
|
||||
/// let delay = Duration::from_millis(300);
|
||||
///
|
||||
/// for attempt in 0..=max_attempts {
|
||||
/// let client = AutofillProviderClient::connect();
|
||||
/// if attempt != 0 {
|
||||
/// // Use whatever sleep method is appropriate
|
||||
/// std::thread::sleep(delay + Duration::from_millis(100 * attempt));
|
||||
/// }
|
||||
/// if let ConnectionStatus::Connected = client.get_connection_status() {
|
||||
/// return Some(client);
|
||||
/// }
|
||||
/// };
|
||||
/// None
|
||||
/// }
|
||||
///
|
||||
/// if let Some(client) = establish_connection() {
|
||||
/// // use client here
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg_attr(target_os = "macos", derive(uniffi::Object))]
|
||||
pub struct AutofillProviderClient {
|
||||
to_server_send: tokio::sync::mpsc::Sender<String>,
|
||||
|
||||
// We need to keep track of the callbacks so we can call them when we receive a response
|
||||
response_callbacks_counter: AtomicU32,
|
||||
#[allow(clippy::type_complexity)]
|
||||
response_callbacks_queue: Arc<Mutex<HashMap<u32, (Box<dyn Callback>, Instant)>>>,
|
||||
|
||||
// Flag to track connection status - atomic for thread safety without locks
|
||||
connection_status: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
/// Store native desktop status information to use for IPC communication
|
||||
/// between the application and the credential provider.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NativeStatus {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
// In our callback management, 0 is a reserved sequence number indicating that a message does not
|
||||
// have a callback.
|
||||
const NO_CALLBACK_INDICATOR: u32 = 0;
|
||||
|
||||
#[cfg(not(test))]
|
||||
static IPC_PATH: &str = "af";
|
||||
#[cfg(test)]
|
||||
static IPC_PATH: &str = "af-test";
|
||||
|
||||
// These methods are not currently needed in macOS and/or cannot be exported via FFI
|
||||
impl AutofillProviderClient {
|
||||
/// Whether the client is immediately available for connection.
|
||||
pub fn is_available() -> bool {
|
||||
desktop_core::ipc::path(IPC_PATH).exists()
|
||||
}
|
||||
|
||||
/// Request the desktop client's lock status.
|
||||
pub fn get_lock_status(&self, callback: Arc<dyn GetLockStatusCallback>) {
|
||||
self.send_message(LockStatusRequest {}, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
/// Requests details about the desktop client's native window.
|
||||
pub fn get_window_handle(&self, callback: Arc<dyn GetWindowHandleQueryCallback>) {
|
||||
self.send_message(
|
||||
WindowHandleQueryRequest::default(),
|
||||
Some(Box::new(callback)),
|
||||
);
|
||||
}
|
||||
|
||||
fn connect_to_path(path: PathBuf) -> Self {
|
||||
let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32);
|
||||
let (to_server_send, to_server_recv) = tokio::sync::mpsc::channel(32);
|
||||
|
||||
let client = AutofillProviderClient {
|
||||
to_server_send,
|
||||
response_callbacks_counter: AtomicU32::new(1), /* Start at 1 since 0 is reserved for
|
||||
* "no callback" scenarios */
|
||||
response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())),
|
||||
connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
let queue = client.response_callbacks_queue.clone();
|
||||
let connection_status = client.connection_status.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Can't create runtime");
|
||||
|
||||
rt.spawn(
|
||||
desktop_core::ipc::client::connect(path.clone(), from_server_send, to_server_recv)
|
||||
.map(move |r| {
|
||||
if let Err(err) = r {
|
||||
tracing::error!(
|
||||
?path,
|
||||
"Failed to connect to autofill IPC server: {err}"
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
rt.block_on(async move {
|
||||
while let Some(message) = from_server_recv.recv().await {
|
||||
match serde_json::from_str::<SerializedMessage>(&message) {
|
||||
Ok(SerializedMessage::Command(CommandMessage::Connected)) => {
|
||||
info!("Connected to server");
|
||||
connection_status.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => {
|
||||
info!("Disconnected from server");
|
||||
connection_status.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Ok(SerializedMessage::Message {
|
||||
sequence_number,
|
||||
value,
|
||||
}) => match queue.lock().expect("not poisoned").remove(&sequence_number) {
|
||||
Some((cb, request_start_time)) => {
|
||||
info!(
|
||||
"Time to process request: {:?}",
|
||||
request_start_time.elapsed()
|
||||
);
|
||||
match value {
|
||||
Ok(value) => {
|
||||
if let Err(e) = cb.complete(value) {
|
||||
error!(error = %e, "Error deserializing message");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Error processing message");
|
||||
cb.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
error!(sequence_number, "No callback found for sequence number");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!(error = %e, %message, "Error deserializing message");
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
client
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "macos", uniffi::export)]
|
||||
impl AutofillProviderClient {
|
||||
/// Asynchronously initiates a connection to the autofill service on the desktop client.
|
||||
///
|
||||
/// See documentation at the top-level of [this struct][AutofillProviderClient] for usage
|
||||
/// information.
|
||||
#[cfg_attr(target_os = "macos", uniffi::constructor)]
|
||||
pub fn connect() -> Self {
|
||||
tracing::trace!("Autofill provider attempting to connect to Electron IPC...");
|
||||
let path = desktop_core::ipc::path(IPC_PATH);
|
||||
Self::connect_to_path(path)
|
||||
}
|
||||
|
||||
/// Send a one-way key-value message to the desktop client.
|
||||
pub fn send_native_status(&self, key: String, value: String) {
|
||||
let status = NativeStatus { key, value };
|
||||
self.send_message(status, None);
|
||||
}
|
||||
|
||||
/// Send a request to create a new passkey to the desktop client.
|
||||
pub fn prepare_passkey_registration(
|
||||
&self,
|
||||
request: PasskeyRegistrationRequest,
|
||||
callback: Arc<dyn PreparePasskeyRegistrationCallback>,
|
||||
) {
|
||||
self.send_message(request, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
/// Send a request to assert a passkey to the desktop client.
|
||||
pub fn prepare_passkey_assertion(
|
||||
&self,
|
||||
request: PasskeyAssertionRequest,
|
||||
callback: Arc<dyn PreparePasskeyAssertionCallback>,
|
||||
) {
|
||||
self.send_message(request, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
/// Send a request to assert a passkey, without prompting the user, to the desktop client.
|
||||
pub fn prepare_passkey_assertion_without_user_interface(
|
||||
&self,
|
||||
request: PasskeyAssertionWithoutUserInterfaceRequest,
|
||||
callback: Arc<dyn PreparePasskeyAssertionCallback>,
|
||||
) {
|
||||
self.send_message(request, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
/// Return the status this client's connection to the desktop client.
|
||||
pub fn get_connection_status(&self) -> ConnectionStatus {
|
||||
let is_connected = self
|
||||
.connection_status
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if is_connected {
|
||||
ConnectionStatus::Connected
|
||||
} else {
|
||||
ConnectionStatus::Disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[uniffi::export]
|
||||
pub fn initialize_logging() {
|
||||
INIT.call_once(|| {
|
||||
let filter = EnvFilter::builder()
|
||||
// Everything logs at `INFO`
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_oslog::OsLogger::new(
|
||||
"com.bitwarden.desktop.autofill-extension",
|
||||
"default",
|
||||
))
|
||||
.init();
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(tag = "command", rename_all = "camelCase")]
|
||||
enum CommandMessage {
|
||||
Connected,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(untagged, rename_all = "camelCase")]
|
||||
enum SerializedMessage {
|
||||
Command(CommandMessage),
|
||||
Message {
|
||||
sequence_number: u32,
|
||||
value: Result<serde_json::Value, BitwardenError>,
|
||||
},
|
||||
}
|
||||
|
||||
impl AutofillProviderClient {
|
||||
fn add_callback(&self, callback: Box<dyn Callback>) -> u32 {
|
||||
let sequence_number = self
|
||||
.response_callbacks_counter
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
self.response_callbacks_queue
|
||||
.lock()
|
||||
.expect("response callbacks queue mutex should not be poisoned")
|
||||
.insert(sequence_number, (callback, Instant::now()));
|
||||
|
||||
sequence_number
|
||||
}
|
||||
|
||||
fn send_message(
|
||||
&self,
|
||||
message: impl Serialize + DeserializeOwned,
|
||||
callback: Option<Box<dyn Callback>>,
|
||||
) {
|
||||
if let ConnectionStatus::Disconnected = self.get_connection_status() {
|
||||
if let Some(callback) = callback {
|
||||
callback.error(BitwardenError::Disconnected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let sequence_number = if let Some(callback) = callback {
|
||||
self.add_callback(callback)
|
||||
} else {
|
||||
NO_CALLBACK_INDICATOR
|
||||
};
|
||||
|
||||
if let Err(e) = send_message_helper(sequence_number, message, &self.to_server_send) {
|
||||
// Make sure we remove the callback from the queue if we can't send the message
|
||||
if sequence_number != NO_CALLBACK_INDICATOR {
|
||||
if let Some((callback, _)) = self
|
||||
.response_callbacks_queue
|
||||
.lock()
|
||||
.expect("response callbacks queue mutex should not be poisoned")
|
||||
.remove(&sequence_number)
|
||||
{
|
||||
callback.error(BitwardenError::Internal(format!(
|
||||
"Error sending message: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapped in Result<> to allow using ? for clarity.
|
||||
fn send_message_helper(
|
||||
sequence_number: u32,
|
||||
message: impl Serialize + DeserializeOwned,
|
||||
tx: &tokio::sync::mpsc::Sender<String>,
|
||||
) -> Result<(), BitwardenError> {
|
||||
let value = serde_json::to_value(message).map_err(|err| {
|
||||
BitwardenError::Internal(format!("Could not represent message as JSON: {err}"))
|
||||
})?;
|
||||
let message = SerializedMessage::Message {
|
||||
sequence_number,
|
||||
value: Ok(value),
|
||||
};
|
||||
let json = serde_json::to_string(&message).map_err(|err| {
|
||||
BitwardenError::Internal(format!("Could not serialize message as JSON: {err}"))
|
||||
})?;
|
||||
// The OS calls us serially, and we only need 1-3 concurrent requests
|
||||
// (passkey request, cancellation, maybe user verification).
|
||||
// So it's safe to send on this thread since there should always be enough
|
||||
// room in the receiver buffer to send.
|
||||
tx.blocking_send(json)
|
||||
.map_err(|_| BitwardenError::Disconnected)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Types of errors for callbacks.
|
||||
#[derive(Debug)]
|
||||
pub enum CallbackError {
|
||||
Timeout,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl Display for CallbackError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Timeout => f.write_str("Callback timed out"),
|
||||
Self::Cancelled => f.write_str("Callback cancelled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for CallbackError {}
|
||||
|
||||
type CallbackResponse<T> = Result<T, BitwardenError>;
|
||||
|
||||
/// An implementation of a callback handler that can take a deadline.
|
||||
pub struct TimedCallback<T> {
|
||||
tx: Arc<Mutex<Option<Sender<CallbackResponse<T>>>>>,
|
||||
rx: Arc<Mutex<Receiver<CallbackResponse<T>>>>,
|
||||
}
|
||||
|
||||
impl<T: Send + 'static> Default for TimedCallback<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Send + 'static> TimedCallback<T> {
|
||||
/// Instantiates a new callback handler.
|
||||
pub fn new() -> Self {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
Self {
|
||||
tx: Arc::new(Mutex::new(Some(tx))),
|
||||
rx: Arc::new(Mutex::new(rx)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Block the current thread until either a response is received, or the
|
||||
/// specified timeout has passed.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use std::{sync::Arc, time::Duration};
|
||||
///
|
||||
/// use autofill_provider::{AutofillProviderClient, TimedCallback};
|
||||
///
|
||||
/// let client = AutofillProviderClient::connect();
|
||||
/// let callback = Arc::new(TimedCallback::new());
|
||||
/// client.get_lock_status(callback.clone());
|
||||
/// match callback.wait_for_response(Duration::from_secs(3), None) {
|
||||
/// Ok(Ok(response)) => Ok(response),
|
||||
/// Ok(Err(err)) => Err(format!("GetLockStatus() call failed: {err}")),
|
||||
/// Err(_) => Err(format!("GetLockStatus() call timed out")),
|
||||
/// }.unwrap();
|
||||
/// ```
|
||||
pub fn wait_for_response(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
cancellation_token: Option<Receiver<()>>,
|
||||
) -> Result<Result<T, BitwardenError>, CallbackError> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
if let Some(cancellation_token) = cancellation_token {
|
||||
let tx2 = tx.clone();
|
||||
let cancellation_token = Mutex::new(cancellation_token);
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(()) = cancellation_token
|
||||
.lock()
|
||||
.expect("not poisoned")
|
||||
.recv_timeout(timeout)
|
||||
{
|
||||
tracing::debug!("Forwarding cancellation");
|
||||
_ = tx2.send(Err(CallbackError::Cancelled));
|
||||
}
|
||||
});
|
||||
}
|
||||
let response_rx = self.rx.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(response) = response_rx
|
||||
.lock()
|
||||
.expect("not poisoned")
|
||||
.recv_timeout(timeout)
|
||||
{
|
||||
_ = tx.send(Ok(response));
|
||||
}
|
||||
});
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(err @ Err(CallbackError::Cancelled)) => {
|
||||
tracing::debug!("Received cancellation, dropping.");
|
||||
err
|
||||
}
|
||||
Ok(err @ Err(CallbackError::Timeout)) => {
|
||||
tracing::warn!("Request timed out, dropping.");
|
||||
err
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => Err(CallbackError::Timeout),
|
||||
Err(_) => Err(CallbackError::Cancelled),
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&self, response: Result<T, BitwardenError>) {
|
||||
match self.tx.lock().expect("not poisoned").take() {
|
||||
Some(tx) => {
|
||||
if tx.send(response).is_err() {
|
||||
tracing::error!("Windows provider channel closed before receiving IPC response from Electron");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::error!("Callback channel used before response: multi-threading issue?");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PreparePasskeyRegistrationCallback for TimedCallback<PasskeyRegistrationResponse> {
|
||||
fn on_complete(&self, credential: PasskeyRegistrationResponse) {
|
||||
self.send(Ok(credential));
|
||||
}
|
||||
|
||||
fn on_error(&self, error: BitwardenError) {
|
||||
self.send(Err(error));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! For debugging test failures, it may be useful to enable tracing to see
|
||||
//! the request flow more easily. You can do that by adding the following
|
||||
//! line to the beginning of the `#[test]` function you're working on:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! tracing_subscriber::fmt::init();
|
||||
//! ```
|
||||
//!
|
||||
//! After that, you can set `RUST_LOG=debug` and run `cargo test` to see the traces.
|
||||
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{atomic::AtomicU32, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use desktop_core::ipc::server::MessageType;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::Level;
|
||||
|
||||
use crate::{
|
||||
AutofillProviderClient, BitwardenError, ConnectionStatus, LockStatusRequest,
|
||||
SerializedMessage, TimedCallback, IPC_PATH,
|
||||
};
|
||||
|
||||
/// Generates a path for a server and client to connect with.
|
||||
///
|
||||
/// [`AutofillProviderClient`] is currently hardcoded to use sockets from the filesystem.
|
||||
/// In order for paths not to conflict between tests, we use a counter and add it to the path
|
||||
/// name.
|
||||
fn get_server_path() -> PathBuf {
|
||||
static SERVER_COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
let counter = SERVER_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let name = format!("{}-{}", IPC_PATH, counter);
|
||||
desktop_core::ipc::path(&name)
|
||||
}
|
||||
|
||||
/// Sets up an in-memory server based on the passed handler and returns a client to the server.
|
||||
fn get_client<
|
||||
F: Fn(Result<Value, BitwardenError>) -> Result<Value, BitwardenError> + Send + 'static,
|
||||
>(
|
||||
handler: F,
|
||||
) -> AutofillProviderClient {
|
||||
let (signal_tx, signal_rx) = std::sync::mpsc::channel();
|
||||
let path = get_server_path();
|
||||
let server_path = path.clone();
|
||||
|
||||
// Start server thread
|
||||
std::thread::spawn(move || {
|
||||
let _span = tracing::span!(Level::DEBUG, "server").entered();
|
||||
tracing::info!("Starting server thread");
|
||||
let (tx, mut rx) = mpsc::channel(8);
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async move {
|
||||
tracing::debug!(?server_path, "Starting server");
|
||||
let server = desktop_core::ipc::server::Server::start(&server_path, tx).unwrap();
|
||||
|
||||
// Signal to main thread that the server is ready to process messages.
|
||||
tracing::debug!("Server started");
|
||||
signal_tx.send(()).unwrap();
|
||||
|
||||
// Handle incoming messages
|
||||
tracing::debug!("Waiting for messages");
|
||||
while let Some(data) = rx.recv().await {
|
||||
tracing::debug!("Received {data:?}");
|
||||
match data.kind {
|
||||
MessageType::Connected => {}
|
||||
MessageType::Disconnected => {}
|
||||
MessageType::Message => {
|
||||
// Deserialize and handle messages using the given handler function.
|
||||
let msg: SerializedMessage =
|
||||
serde_json::from_str(&data.message.unwrap()).unwrap();
|
||||
|
||||
if let SerializedMessage::Message {
|
||||
sequence_number,
|
||||
value,
|
||||
} = msg
|
||||
{
|
||||
let response = serde_json::to_string(&SerializedMessage::Message {
|
||||
sequence_number,
|
||||
value: handler(value),
|
||||
})
|
||||
.unwrap();
|
||||
server.send(response).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for server to startup and client to connect to server before returning client to
|
||||
// test method.
|
||||
let _span = tracing::span!(Level::DEBUG, "client");
|
||||
tracing::debug!("Waiting for server...");
|
||||
signal_rx.recv_timeout(Duration::from_millis(1000)).unwrap();
|
||||
|
||||
// This starts a background task to connect to the server.
|
||||
tracing::debug!("Starting client...");
|
||||
let client = AutofillProviderClient::connect_to_path(path.to_path_buf());
|
||||
|
||||
// The client connects to the server asynchronously in a background
|
||||
// thread, so wait for client to report itself as Connected so that test
|
||||
// methods don't have to do this everytime.
|
||||
// Note, this has the potential to be flaky on a very busy server, but that's unavoidable
|
||||
// with the current API.
|
||||
tracing::debug!("Client connecting...");
|
||||
for _ in 0..20 {
|
||||
if let ConnectionStatus::Connected = client.get_connection_status() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
client.get_connection_status(),
|
||||
ConnectionStatus::Connected
|
||||
));
|
||||
|
||||
client
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_throws_error_on_method_call_when_disconnected() {
|
||||
// There is no server running at this path, so this client should always be disconnected.
|
||||
let client = AutofillProviderClient::connect_to_path(get_server_path());
|
||||
|
||||
// use an arbitrary request to test whether the client is disconnected.
|
||||
let callback = Arc::new(TimedCallback::new());
|
||||
client.get_lock_status(callback.clone());
|
||||
let response = callback
|
||||
.wait_for_response(Duration::from_millis(10), None)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(response, Err(BitwardenError::Disconnected)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_parses_get_lock_status_response_when_valid_json_is_returned() {
|
||||
// The server should expect a lock status request and return a valid response.
|
||||
let handler = |value: Result<Value, BitwardenError>| {
|
||||
let value = value.unwrap();
|
||||
if let Ok(LockStatusRequest {}) = serde_json::from_value(value.clone()) {
|
||||
Ok(json!({"isUnlocked": true}))
|
||||
} else {
|
||||
Err(BitwardenError::Internal(format!(
|
||||
"Expected LockStatusRequest, received: {value:?}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
// send a lock status request
|
||||
let client = get_client(handler);
|
||||
let callback = Arc::new(TimedCallback::new());
|
||||
client.get_lock_status(callback.clone());
|
||||
let response = callback
|
||||
.wait_for_response(Duration::from_millis(3000), None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert!(response.is_unlocked);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{BitwardenError, Callback, TimedCallback};
|
||||
|
||||
/// Request to retrieve the lock status of the desktop client.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct LockStatusRequest {}
|
||||
|
||||
/// Response for the lock status of the desktop client.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LockStatusResponse {
|
||||
/// Whether the desktop client is unlocked.
|
||||
#[serde(rename = "isUnlocked")]
|
||||
pub is_unlocked: bool,
|
||||
}
|
||||
|
||||
impl Callback for Arc<dyn GetLockStatusCallback> {
|
||||
fn complete(&self, response: serde_json::Value) -> Result<(), serde_json::Error> {
|
||||
let response = serde_json::from_value(response)?;
|
||||
self.as_ref().on_complete(response);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn error(&self, error: BitwardenError) {
|
||||
self.as_ref().on_error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback to process a response to a lock status request.
|
||||
pub trait GetLockStatusCallback: Send + Sync {
|
||||
/// Function to call if a successful response is returned.
|
||||
fn on_complete(&self, response: LockStatusResponse);
|
||||
|
||||
/// Function to call if an error response is returned.
|
||||
fn on_error(&self, error: BitwardenError);
|
||||
}
|
||||
|
||||
impl GetLockStatusCallback for TimedCallback<LockStatusResponse> {
|
||||
fn on_complete(&self, response: LockStatusResponse) {
|
||||
self.send(Ok(response));
|
||||
}
|
||||
|
||||
fn on_error(&self, error: BitwardenError) {
|
||||
self.send(Err(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{BitwardenError, Callback, Position, UserVerification};
|
||||
|
||||
/// Request to create a credential.
|
||||
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyRegistrationRequest {
|
||||
/// Relying Party ID for the request.
|
||||
pub rp_id: String,
|
||||
|
||||
/// The user name for the credential that was previously given to the OS.
|
||||
pub user_name: String,
|
||||
|
||||
/// The user ID for the credential that was previously given to the OS.
|
||||
pub user_handle: Vec<u8>,
|
||||
|
||||
/// SHA-256 hash of the `clientDataJSON` for the registration request.
|
||||
pub client_data_hash: Vec<u8>,
|
||||
|
||||
/// User verification preference.
|
||||
pub user_verification: UserVerification,
|
||||
|
||||
/// Supported key algorithms in COSE format.
|
||||
pub supported_algorithms: Vec<i32>,
|
||||
|
||||
/// Coordinates of the center of the WebAuthn client's window, relative to
|
||||
/// the top-left point on the screen.
|
||||
/// # Operating System Differences
|
||||
///
|
||||
/// ## macOS
|
||||
/// Note that macOS APIs gives points relative to the bottom-left point on the
|
||||
/// screen by default, so the y-coordinate will be flipped.
|
||||
///
|
||||
/// ## Windows
|
||||
/// On Windows, this must be logical pixels, not physical pixels.
|
||||
pub window_xy: Position,
|
||||
|
||||
/// List of excluded credential IDs.
|
||||
pub excluded_credentials: Vec<Vec<u8>>,
|
||||
|
||||
/// Byte string representing the native OS window handle for the WebAuthn client.
|
||||
/// # Operating System Differences
|
||||
///
|
||||
/// ## macOS
|
||||
/// Unused.
|
||||
///
|
||||
/// ## Windows
|
||||
/// On Windows, this is a HWND.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub client_window_handle: Vec<u8>,
|
||||
|
||||
/// Native context required for callbacks to the OS. Format differs by OS.
|
||||
/// # Operating System Differences
|
||||
///
|
||||
/// ## macOS
|
||||
/// Unused.
|
||||
///
|
||||
/// ## Windows
|
||||
/// On Windows, this is a base64-string representing the following data:
|
||||
/// `request transaction id (GUID, 16 bytes) || SHA-256(pluginOperationRequest)`
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub context: String,
|
||||
}
|
||||
|
||||
/// Response for a passkey registration request.
|
||||
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyRegistrationResponse {
|
||||
/// Relying Party ID.
|
||||
pub rp_id: String,
|
||||
|
||||
/// SHA-256 hash of the `clientDataJSON` used in the registration.
|
||||
pub client_data_hash: Vec<u8>,
|
||||
|
||||
/// The ID for the created credential.
|
||||
pub credential_id: Vec<u8>,
|
||||
|
||||
/// WebAuthn attestation object.
|
||||
pub attestation_object: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Callback to process a response to passkey registration request.
|
||||
#[cfg_attr(target_os = "macos", uniffi::export(with_foreign))]
|
||||
pub trait PreparePasskeyRegistrationCallback: Send + Sync {
|
||||
/// Function to call if a successful response is returned.
|
||||
fn on_complete(&self, credential: PasskeyRegistrationResponse);
|
||||
|
||||
/// Function to call if an error response is returned.
|
||||
fn on_error(&self, error: BitwardenError);
|
||||
}
|
||||
|
||||
impl Callback for Arc<dyn PreparePasskeyRegistrationCallback> {
|
||||
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
|
||||
let credential = serde_json::from_value(credential)?;
|
||||
PreparePasskeyRegistrationCallback::on_complete(self.as_ref(), credential);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn error(&self, error: BitwardenError) {
|
||||
PreparePasskeyRegistrationCallback::on_error(self.as_ref(), error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{
|
||||
base64::{Base64, Standard},
|
||||
formats::Padded,
|
||||
serde_as,
|
||||
};
|
||||
|
||||
use crate::{BitwardenError, Callback, TimedCallback};
|
||||
|
||||
/// Request to get the window handle of the desktop client.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct WindowHandleQueryRequest {
|
||||
/// Marker field for parsing; data is never read.
|
||||
///
|
||||
/// TODO: this is used to disambiguate parsing the type in desktop_napi.
|
||||
/// This will be cleaned up in PM-23485.
|
||||
window_handle: String,
|
||||
}
|
||||
|
||||
/// Response to window handle request.
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WindowHandleQueryResponse {
|
||||
/// Whether the desktop client is currently visible.
|
||||
pub is_visible: bool,
|
||||
|
||||
/// Whether the desktop client is currently focused.
|
||||
pub is_focused: bool,
|
||||
|
||||
/// Byte string representing the native OS window handle for the desktop client.
|
||||
/// # Operating System Differences
|
||||
///
|
||||
/// ## macOS
|
||||
/// Unused.
|
||||
///
|
||||
/// ## Windows
|
||||
/// On Windows, this is a HWND.
|
||||
#[serde_as(as = "Base64<Standard, Padded>")]
|
||||
pub handle: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Callback for Arc<dyn GetWindowHandleQueryCallback> {
|
||||
fn complete(&self, response: serde_json::Value) -> Result<(), serde_json::Error> {
|
||||
let response = serde_json::from_value(response)?;
|
||||
self.as_ref().on_complete(response);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn error(&self, error: BitwardenError) {
|
||||
self.as_ref().on_error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback to process a response to a window handle query request.
|
||||
pub trait GetWindowHandleQueryCallback: Send + Sync {
|
||||
/// Function to call if a successful response is returned.
|
||||
fn on_complete(&self, response: WindowHandleQueryResponse);
|
||||
|
||||
/// Function to call if an error response is returned.
|
||||
fn on_error(&self, error: BitwardenError);
|
||||
}
|
||||
|
||||
impl GetWindowHandleQueryCallback for TimedCallback<WindowHandleQueryResponse> {
|
||||
fn on_complete(&self, response: WindowHandleQueryResponse) {
|
||||
self.send(Ok(response));
|
||||
}
|
||||
|
||||
fn on_error(&self, error: BitwardenError) {
|
||||
self.send(Err(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
uniffi::uniffi_bindgen_main()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn main() {
|
||||
unimplemented!("uniffi-bindgen is not enabled on this target.");
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{BitwardenError, Callback, Position, UserVerification};
|
||||
|
||||
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyAssertionRequest {
|
||||
rp_id: String,
|
||||
client_data_hash: Vec<u8>,
|
||||
user_verification: UserVerification,
|
||||
allowed_credentials: Vec<Vec<u8>>,
|
||||
window_xy: Position,
|
||||
//extension_input: Vec<u8>, TODO: Implement support for extensions
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
|
||||
rp_id: String,
|
||||
credential_id: Vec<u8>,
|
||||
user_name: String,
|
||||
user_handle: Vec<u8>,
|
||||
record_identifier: Option<String>,
|
||||
client_data_hash: Vec<u8>,
|
||||
user_verification: UserVerification,
|
||||
window_xy: Position,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyAssertionResponse {
|
||||
rp_id: String,
|
||||
user_handle: Vec<u8>,
|
||||
signature: Vec<u8>,
|
||||
client_data_hash: Vec<u8>,
|
||||
authenticator_data: Vec<u8>,
|
||||
credential_id: Vec<u8>,
|
||||
}
|
||||
|
||||
#[uniffi::export(with_foreign)]
|
||||
pub trait PreparePasskeyAssertionCallback: Send + Sync {
|
||||
fn on_complete(&self, credential: PasskeyAssertionResponse);
|
||||
fn on_error(&self, error: BitwardenError);
|
||||
}
|
||||
|
||||
impl Callback for Arc<dyn PreparePasskeyAssertionCallback> {
|
||||
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
|
||||
let credential = serde_json::from_value(credential)?;
|
||||
PreparePasskeyAssertionCallback::on_complete(self.as_ref(), credential);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn error(&self, error: BitwardenError) {
|
||||
PreparePasskeyAssertionCallback::on_error(self.as_ref(), error);
|
||||
}
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{atomic::AtomicU32, Arc, Mutex, Once},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use futures::FutureExt;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use tracing::{error, info};
|
||||
use tracing_subscriber::{
|
||||
filter::{EnvFilter, LevelFilter},
|
||||
layer::SubscriberExt,
|
||||
util::SubscriberInitExt,
|
||||
};
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
|
||||
mod assertion;
|
||||
mod registration;
|
||||
|
||||
use assertion::{
|
||||
PasskeyAssertionRequest, PasskeyAssertionWithoutUserInterfaceRequest,
|
||||
PreparePasskeyAssertionCallback,
|
||||
};
|
||||
use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback};
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
#[derive(uniffi::Enum, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UserVerification {
|
||||
Preferred,
|
||||
Required,
|
||||
Discouraged,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Position {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, uniffi::Error, Serialize, Deserialize)]
|
||||
pub enum BitwardenError {
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
// TODO: These have to be named differently than the actual Uniffi traits otherwise
|
||||
// the generated code will lead to ambiguous trait implementations
|
||||
// These are only used internally, so it doesn't matter that much
|
||||
trait Callback: Send + Sync {
|
||||
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error>;
|
||||
fn error(&self, error: BitwardenError);
|
||||
}
|
||||
|
||||
#[derive(uniffi::Enum, Debug)]
|
||||
/// Store the connection status between the macOS credential provider extension
|
||||
/// and the desktop application's IPC server.
|
||||
pub enum ConnectionStatus {
|
||||
Connected,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct MacOSProviderClient {
|
||||
to_server_send: tokio::sync::mpsc::Sender<String>,
|
||||
|
||||
// We need to keep track of the callbacks so we can call them when we receive a response
|
||||
response_callbacks_counter: AtomicU32,
|
||||
#[allow(clippy::type_complexity)]
|
||||
response_callbacks_queue: Arc<Mutex<HashMap<u32, (Box<dyn Callback>, Instant)>>>,
|
||||
|
||||
// Flag to track connection status - atomic for thread safety without locks
|
||||
connection_status: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
/// Store native desktop status information to use for IPC communication
|
||||
/// between the application and the macOS credential provider.
|
||||
pub struct NativeStatus {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
// In our callback management, 0 is a reserved sequence number indicating that a message does not
|
||||
// have a callback.
|
||||
const NO_CALLBACK_INDICATOR: u32 = 0;
|
||||
|
||||
#[uniffi::export]
|
||||
impl MacOSProviderClient {
|
||||
// FIXME: Remove unwraps! They panic and terminate the whole application.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
#[uniffi::constructor]
|
||||
pub fn connect() -> Self {
|
||||
INIT.call_once(|| {
|
||||
let filter = EnvFilter::builder()
|
||||
// Everything logs at `INFO`
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_oslog::OsLogger::new(
|
||||
"com.bitwarden.desktop.autofill-extension",
|
||||
"default",
|
||||
))
|
||||
.init();
|
||||
});
|
||||
|
||||
let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32);
|
||||
let (to_server_send, to_server_recv) = tokio::sync::mpsc::channel(32);
|
||||
|
||||
let client = MacOSProviderClient {
|
||||
to_server_send,
|
||||
response_callbacks_counter: AtomicU32::new(1), /* Start at 1 since 0 is reserved for
|
||||
* "no callback" scenarios */
|
||||
response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())),
|
||||
connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
let path = desktop_core::ipc::path("af");
|
||||
|
||||
let queue = client.response_callbacks_queue.clone();
|
||||
let connection_status = client.connection_status.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Can't create runtime");
|
||||
|
||||
rt.spawn(
|
||||
desktop_core::ipc::client::connect(path, from_server_send, to_server_recv)
|
||||
.map(|r| r.map_err(|e| e.to_string())),
|
||||
);
|
||||
|
||||
rt.block_on(async move {
|
||||
while let Some(message) = from_server_recv.recv().await {
|
||||
match serde_json::from_str::<SerializedMessage>(&message) {
|
||||
Ok(SerializedMessage::Command(CommandMessage::Connected)) => {
|
||||
info!("Connected to server");
|
||||
connection_status.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => {
|
||||
info!("Disconnected from server");
|
||||
connection_status.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Ok(SerializedMessage::Message {
|
||||
sequence_number,
|
||||
value,
|
||||
}) => match queue.lock().unwrap().remove(&sequence_number) {
|
||||
Some((cb, request_start_time)) => {
|
||||
info!(
|
||||
"Time to process request: {:?}",
|
||||
request_start_time.elapsed()
|
||||
);
|
||||
match value {
|
||||
Ok(value) => {
|
||||
if let Err(e) = cb.complete(value) {
|
||||
error!(error = %e, "Error deserializing message");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Error processing message");
|
||||
cb.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
error!(sequence_number, "No callback found for sequence number")
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!(error = %e, "Error deserializing message");
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
client
|
||||
}
|
||||
|
||||
pub fn send_native_status(&self, key: String, value: String) {
|
||||
let status = NativeStatus { key, value };
|
||||
self.send_message(status, None);
|
||||
}
|
||||
|
||||
pub fn prepare_passkey_registration(
|
||||
&self,
|
||||
request: PasskeyRegistrationRequest,
|
||||
callback: Arc<dyn PreparePasskeyRegistrationCallback>,
|
||||
) {
|
||||
self.send_message(request, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
pub fn prepare_passkey_assertion(
|
||||
&self,
|
||||
request: PasskeyAssertionRequest,
|
||||
callback: Arc<dyn PreparePasskeyAssertionCallback>,
|
||||
) {
|
||||
self.send_message(request, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
pub fn prepare_passkey_assertion_without_user_interface(
|
||||
&self,
|
||||
request: PasskeyAssertionWithoutUserInterfaceRequest,
|
||||
callback: Arc<dyn PreparePasskeyAssertionCallback>,
|
||||
) {
|
||||
self.send_message(request, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
pub fn get_connection_status(&self) -> ConnectionStatus {
|
||||
let is_connected = self
|
||||
.connection_status
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if is_connected {
|
||||
ConnectionStatus::Connected
|
||||
} else {
|
||||
ConnectionStatus::Disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(tag = "command", rename_all = "camelCase")]
|
||||
enum CommandMessage {
|
||||
Connected,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(untagged, rename_all = "camelCase")]
|
||||
enum SerializedMessage {
|
||||
Command(CommandMessage),
|
||||
Message {
|
||||
sequence_number: u32,
|
||||
value: Result<serde_json::Value, BitwardenError>,
|
||||
},
|
||||
}
|
||||
|
||||
impl MacOSProviderClient {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
fn add_callback(&self, callback: Box<dyn Callback>) -> u32 {
|
||||
let sequence_number = self
|
||||
.response_callbacks_counter
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
self.response_callbacks_queue
|
||||
.lock()
|
||||
.expect("response callbacks queue mutex should not be poisoned")
|
||||
.insert(sequence_number, (callback, Instant::now()));
|
||||
|
||||
sequence_number
|
||||
}
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
fn send_message(
|
||||
&self,
|
||||
message: impl Serialize + DeserializeOwned,
|
||||
callback: Option<Box<dyn Callback>>,
|
||||
) {
|
||||
let sequence_number = if let Some(callback) = callback {
|
||||
self.add_callback(callback)
|
||||
} else {
|
||||
NO_CALLBACK_INDICATOR
|
||||
};
|
||||
|
||||
let message = serde_json::to_string(&SerializedMessage::Message {
|
||||
sequence_number,
|
||||
value: Ok(serde_json::to_value(message).unwrap()),
|
||||
})
|
||||
.expect("Can't serialize message");
|
||||
|
||||
if let Err(e) = self.to_server_send.blocking_send(message) {
|
||||
// Make sure we remove the callback from the queue if we can't send the message
|
||||
if sequence_number != NO_CALLBACK_INDICATOR {
|
||||
if let Some((callback, _)) = self
|
||||
.response_callbacks_queue
|
||||
.lock()
|
||||
.expect("response callbacks queue mutex should not be poisoned")
|
||||
.remove(&sequence_number)
|
||||
{
|
||||
callback.error(BitwardenError::Internal(format!(
|
||||
"Error sending message: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{BitwardenError, Callback, Position, UserVerification};
|
||||
|
||||
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyRegistrationRequest {
|
||||
rp_id: String,
|
||||
user_name: String,
|
||||
user_handle: Vec<u8>,
|
||||
client_data_hash: Vec<u8>,
|
||||
user_verification: UserVerification,
|
||||
supported_algorithms: Vec<i32>,
|
||||
window_xy: Position,
|
||||
excluded_credentials: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyRegistrationResponse {
|
||||
rp_id: String,
|
||||
client_data_hash: Vec<u8>,
|
||||
credential_id: Vec<u8>,
|
||||
attestation_object: Vec<u8>,
|
||||
}
|
||||
|
||||
#[uniffi::export(with_foreign)]
|
||||
pub trait PreparePasskeyRegistrationCallback: Send + Sync {
|
||||
fn on_complete(&self, credential: PasskeyRegistrationResponse);
|
||||
fn on_error(&self, error: BitwardenError);
|
||||
}
|
||||
|
||||
impl Callback for Arc<dyn PreparePasskeyRegistrationCallback> {
|
||||
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
|
||||
let credential = serde_json::from_value(credential)?;
|
||||
PreparePasskeyRegistrationCallback::on_complete(self.as_ref(), credential);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn error(&self, error: BitwardenError) {
|
||||
PreparePasskeyRegistrationCallback::on_error(self.as_ref(), error);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
uniffi::uniffi_bindgen_main()
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
@IBOutlet weak var logoImageView: NSImageView!
|
||||
|
||||
// The IPC client to communicate with the Bitwarden desktop app
|
||||
private var client: MacOsProviderClient?
|
||||
private var client: AutofillProviderClient?
|
||||
|
||||
// Timer for checking connection status
|
||||
private var connectionMonitorTimer: Timer?
|
||||
@@ -25,11 +25,12 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
// This is so that we can check if the app is running, and launch it, without blocking the main thread
|
||||
// Blocking the main thread caused MacOS layouting to 'fail' or at least be very delayed, which caused our getWindowPositioning code to sent 0,0.
|
||||
// We also properly retry the IPC connection which sometimes would take some time to be up and running, depending on CPU load, phase of jupiters moon, etc.
|
||||
private func getClient() async -> MacOsProviderClient {
|
||||
private func getClient() async -> AutofillProviderClient {
|
||||
if let client = self.client {
|
||||
return client
|
||||
}
|
||||
|
||||
initializeLogging()
|
||||
let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
|
||||
|
||||
// Check if the Electron app is running
|
||||
@@ -61,13 +62,13 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
// Retry connecting to the Bitwarden IPC with an increasing delay
|
||||
let maxRetries = 20
|
||||
let delayMs = 500
|
||||
var newClient: MacOsProviderClient?
|
||||
var newClient: AutofillProviderClient?
|
||||
|
||||
for attempt in 1...maxRetries {
|
||||
logger.log("[autofill-extension] Connection attempt \(attempt)")
|
||||
|
||||
// Create a new client instance for each retry
|
||||
newClient = MacOsProviderClient.connect()
|
||||
newClient = AutofillProviderClient.connect()
|
||||
try? await Task.sleep(nanoseconds: UInt64(100 * attempt + (delayMs * 1_000_000))) // Convert ms to nanoseconds
|
||||
let connectionStatus = newClient!.getConnectionStatus()
|
||||
|
||||
@@ -129,7 +130,7 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
|
||||
// If we just disconnected, try to cancel the request
|
||||
if currentStatus == .disconnected {
|
||||
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Bitwarden desktop app disconnected"))
|
||||
self.extensionContext.cancelRequest(withError: BitwardenError.Disconnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/macos_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = "<group>"; };
|
||||
3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/autofill_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = "<group>"; };
|
||||
3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BitwardenMacosProvider.swift; sourceTree = "<group>"; };
|
||||
968ED08A2C52A47200FFFEE6 /* ReleaseAppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseAppStore.xcconfig; sourceTree = "<group>"; };
|
||||
9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "bitwarden-icon.png"; sourceTree = "<group>"; };
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.3",
|
||||
"@types/node": "22.19.7",
|
||||
"typescript": "5.4.2"
|
||||
}
|
||||
},
|
||||
@@ -117,10 +117,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||
"version": "22.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -379,6 +380,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz",
|
||||
"integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.3",
|
||||
"@types/node": "22.19.7",
|
||||
"typescript": "5.4.2"
|
||||
},
|
||||
"_moduleAliases": {
|
||||
|
||||
@@ -18,16 +18,16 @@
|
||||
"scripts": {
|
||||
"postinstall": "electron-rebuild",
|
||||
"start": "cross-env ELECTRON_IS_DEV=0 ELECTRON_NO_UPDATER=1 electron ./build",
|
||||
"build-native-macos": "cd desktop_native && ./macos_provider/build.sh && node build.js cross-platform",
|
||||
"build-native-macos": "cd desktop_native && ./autofill_provider/build.sh && node build.js cross-platform",
|
||||
"build-native": "cd desktop_native && node build.js",
|
||||
"build": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\" \"npm run build:preload\"",
|
||||
"build:dev": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main:dev\" \"npm run build:renderer:dev\" \"npm run build:preload:dev\"",
|
||||
"build:preload": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload",
|
||||
"build:preload:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload",
|
||||
"build:preload:watch": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload --watch",
|
||||
"build:macos-extension:mac": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mac",
|
||||
"build:macos-extension:mas": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas",
|
||||
"build:macos-extension:masdev": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas-dev",
|
||||
"build:macos-extension:mac": "./desktop_native/autofill_provider/build.sh && node scripts/build-macos-extension.js mac",
|
||||
"build:macos-extension:mas": "./desktop_native/autofill_provider/build.sh && node scripts/build-macos-extension.js mas",
|
||||
"build:macos-extension:masdev": "./desktop_native/autofill_provider/build.sh && node scripts/build-macos-extension.js mas-dev",
|
||||
"build:main": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main",
|
||||
"build:main:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main",
|
||||
"build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main --watch",
|
||||
|
||||
@@ -300,7 +300,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
|
||||
const initialValues = {
|
||||
pin: this.userHasPinSet,
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(),
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(activeAccount.id),
|
||||
requireMasterPasswordOnAppRestart: !(await this.biometricsService.hasPersistentKey(
|
||||
activeAccount.id,
|
||||
)),
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
LoginEmailService,
|
||||
SsoUrlService,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
|
||||
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
@@ -123,6 +125,8 @@ import {
|
||||
import {
|
||||
LockComponentService,
|
||||
SessionTimeoutSettingsComponentService,
|
||||
WebAuthnPrfUnlockService,
|
||||
DefaultWebAuthnPrfUnlockService,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
import { SerializedMemoryStorageService } from "@bitwarden/storage-core";
|
||||
import {
|
||||
@@ -413,6 +417,21 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DesktopLockComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: WebAuthnPrfUnlockService,
|
||||
useClass: DefaultWebAuthnPrfUnlockService,
|
||||
deps: [
|
||||
WebAuthnLoginPrfKeyServiceAbstraction,
|
||||
KeyServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
EncryptService,
|
||||
EnvironmentService,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
WINDOW,
|
||||
LogServiceAbstraction,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CLIENT_TYPE,
|
||||
useValue: ClientType.Desktop,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import {
|
||||
combineLatest,
|
||||
firstValueFrom,
|
||||
Subject,
|
||||
takeUntil,
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
CipherFormModule,
|
||||
CipherViewComponent,
|
||||
CollectionAssignmentResult,
|
||||
createFilterFunction,
|
||||
DecryptionFailureDialogComponent,
|
||||
DefaultChangeLoginPasswordService,
|
||||
DefaultCipherFormConfigService,
|
||||
@@ -79,6 +81,7 @@ import {
|
||||
VaultFilter,
|
||||
VaultFilterServiceAbstraction as VaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
RoutedVaultFilterService,
|
||||
VaultItemsTransferService,
|
||||
DefaultVaultItemsTransferService,
|
||||
} from "@bitwarden/vault";
|
||||
@@ -216,6 +219,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
|
||||
private policyService: PolicyService,
|
||||
private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
|
||||
private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService,
|
||||
private routedVaultFilterService: RoutedVaultFilterService,
|
||||
private vaultFilterService: VaultFilterService,
|
||||
private vaultItemTransferService: VaultItemsTransferService,
|
||||
) {}
|
||||
@@ -234,9 +238,16 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
|
||||
});
|
||||
|
||||
// Subscribe to filter changes from router params via the bridge service
|
||||
this.routedVaultFilterBridgeService.activeFilter$
|
||||
// Use combineLatest to react to changes in both the filter and archive flag
|
||||
combineLatest([
|
||||
this.routedVaultFilterBridgeService.activeFilter$,
|
||||
this.routedVaultFilterService.filter$,
|
||||
this.cipherArchiveService.hasArchiveFlagEnabled$,
|
||||
])
|
||||
.pipe(
|
||||
switchMap((vaultFilter: VaultFilter) => from(this.applyVaultFilter(vaultFilter))),
|
||||
switchMap(([vaultFilter, routedFilter, archiveEnabled]) =>
|
||||
from(this.applyVaultFilter(vaultFilter, routedFilter, archiveEnabled)),
|
||||
),
|
||||
takeUntil(this.componentIsDestroyed$),
|
||||
)
|
||||
.subscribe();
|
||||
@@ -789,48 +800,19 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
|
||||
await this.go().catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a filter function to handle CipherListView objects.
|
||||
* CipherListView has a different type structure where type can be a string or object.
|
||||
* This wrapper converts it to CipherView-compatible structure before filtering.
|
||||
*/
|
||||
private wrapFilterForCipherListView(
|
||||
filterFn: (cipher: CipherView) => boolean,
|
||||
): (cipher: CipherViewLike) => boolean {
|
||||
return (cipher: CipherViewLike) => {
|
||||
// For CipherListView, create a proxy object with the correct type property
|
||||
if (CipherViewLikeUtils.isCipherListView(cipher)) {
|
||||
const proxyCipher = {
|
||||
...cipher,
|
||||
type: CipherViewLikeUtils.getType(cipher),
|
||||
// Normalize undefined organizationId to null for filter compatibility
|
||||
organizationId: cipher.organizationId ?? null,
|
||||
// Normalize empty string folderId to null for filter compatibility
|
||||
folderId: cipher.folderId ? cipher.folderId : null,
|
||||
// Explicitly include isDeleted and isArchived since they might be getters
|
||||
isDeleted: CipherViewLikeUtils.isDeleted(cipher),
|
||||
isArchived: CipherViewLikeUtils.isArchived(cipher),
|
||||
};
|
||||
return filterFn(proxyCipher as any);
|
||||
}
|
||||
return filterFn(cipher);
|
||||
};
|
||||
}
|
||||
|
||||
async applyVaultFilter(vaultFilter: VaultFilter) {
|
||||
async applyVaultFilter(
|
||||
vaultFilter: VaultFilter,
|
||||
routedFilter: Parameters<typeof createFilterFunction>[0],
|
||||
archiveEnabled: boolean,
|
||||
) {
|
||||
this.searchBarService.setPlaceholderText(
|
||||
this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)),
|
||||
);
|
||||
this.activeFilter = vaultFilter;
|
||||
|
||||
const originalFilterFn = this.activeFilter.buildFilter();
|
||||
const wrappedFilterFn = this.wrapFilterForCipherListView(originalFilterFn);
|
||||
const filterFn = createFilterFunction(routedFilter, archiveEnabled);
|
||||
|
||||
await this.vaultItemsComponent?.reload(
|
||||
wrappedFilterFn,
|
||||
vaultFilter.isDeleted,
|
||||
vaultFilter.isArchived,
|
||||
);
|
||||
await this.vaultItemsComponent?.reload(filterFn, vaultFilter.isDeleted, vaultFilter.isArchived);
|
||||
}
|
||||
|
||||
private getAvailableCollections(cipher: CipherView): CollectionView[] {
|
||||
|
||||
@@ -514,7 +514,7 @@ export class vNextMembersComponent {
|
||||
if (result.error != null) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t(result.error),
|
||||
message: result.error,
|
||||
});
|
||||
this.logService.error(result.error);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
AutoConfirmPolicyDialogComponent,
|
||||
AutoConfirmPolicyDialogData,
|
||||
} from "./auto-confirm-edit-policy-dialog.component";
|
||||
|
||||
describe("AutoConfirmPolicyDialogComponent", () => {
|
||||
let component: AutoConfirmPolicyDialogComponent;
|
||||
let fixture: ComponentFixture<AutoConfirmPolicyDialogComponent>;
|
||||
|
||||
let mockPolicyApiService: MockProxy<PolicyApiServiceAbstraction>;
|
||||
let mockAccountService: FakeAccountService;
|
||||
let mockOrganizationService: MockProxy<OrganizationService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let mockRouter: MockProxy<Router>;
|
||||
let mockAutoConfirmService: MockProxy<AutomaticUserConfirmationService>;
|
||||
let mockDialogRef: MockProxy<DialogRef>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockKeyService: MockProxy<KeyService>;
|
||||
|
||||
const mockUserId = newGuid() as UserId;
|
||||
const mockOrgId = newGuid() as OrganizationId;
|
||||
|
||||
const mockDialogData: AutoConfirmPolicyDialogData = {
|
||||
organizationId: mockOrgId,
|
||||
policy: {
|
||||
name: "autoConfirm",
|
||||
description: "Auto Confirm Policy",
|
||||
type: PolicyType.AutoConfirm,
|
||||
component: {} as any,
|
||||
showDescription: true,
|
||||
display$: () => of(true),
|
||||
},
|
||||
firstTimeDialog: false,
|
||||
};
|
||||
|
||||
const mockOrg = {
|
||||
id: mockOrgId,
|
||||
name: "Test Organization",
|
||||
enabled: true,
|
||||
isAdmin: true,
|
||||
canManagePolicies: true,
|
||||
} as Organization;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockPolicyApiService = mock<PolicyApiServiceAbstraction>();
|
||||
mockAccountService = mockAccountServiceWith(mockUserId);
|
||||
mockOrganizationService = mock<OrganizationService>();
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
mockRouter = mock<Router>();
|
||||
mockAutoConfirmService = mock<AutomaticUserConfirmationService>();
|
||||
mockDialogRef = mock<DialogRef>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockKeyService = mock<KeyService>();
|
||||
|
||||
mockPolicyService.policies$.mockReturnValue(of([]));
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([mockOrg]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AutoConfirmPolicyDialogComponent],
|
||||
providers: [
|
||||
FormBuilder,
|
||||
{ provide: DIALOG_DATA, useValue: mockDialogData },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: KeyService, useValue: mockKeyService },
|
||||
{ provide: OrganizationService, useValue: mockOrganizationService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: AutomaticUserConfirmationService, useValue: mockAutoConfirmService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
})
|
||||
.overrideComponent(AutoConfirmPolicyDialogComponent, {
|
||||
set: { template: "<div></div>" },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AutoConfirmPolicyDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("handleSubmit", () => {
|
||||
beforeEach(() => {
|
||||
// Mock the policyComponent
|
||||
component.policyComponent = {
|
||||
buildRequest: jest.fn().mockResolvedValue({ enabled: true, data: null }),
|
||||
enabled: { value: true },
|
||||
setSingleOrgEnabled: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockAutoConfirmService.configuration$.mockReturnValue(
|
||||
of({ enabled: false, showSetupDialog: true, showBrowserNotification: undefined }),
|
||||
);
|
||||
mockAutoConfirmService.upsert.mockResolvedValue(undefined);
|
||||
mockI18nService.t.mockReturnValue("Policy updated");
|
||||
});
|
||||
|
||||
it("should enable SingleOrg policy when it was not already enabled", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
// Call handleSubmit with singleOrgEnabled = false (meaning it needs to be enabled)
|
||||
await component["handleSubmit"](false);
|
||||
|
||||
// First call should be SingleOrg enable
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not enable SingleOrg policy when it was already enabled", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
// Call handleSubmit with singleOrgEnabled = true (meaning it's already enabled)
|
||||
await component["handleSubmit"](true);
|
||||
|
||||
// Should only call putPolicyVNext once (for AutoConfirm, not SingleOrg)
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
PolicyType.AutoConfirm,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should rollback SingleOrg policy when AutoConfirm fails and SingleOrg was enabled during action", async () => {
|
||||
const autoConfirmError = new Error("AutoConfirm failed");
|
||||
|
||||
// First call (SingleOrg enable) succeeds, second call (AutoConfirm) fails, third call (SingleOrg rollback) succeeds
|
||||
mockPolicyApiService.putPolicyVNext
|
||||
.mockResolvedValueOnce({} as any) // SingleOrg enable
|
||||
.mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails
|
||||
.mockResolvedValueOnce({} as any); // SingleOrg rollback
|
||||
|
||||
await expect(component["handleSubmit"](false)).rejects.toThrow("AutoConfirm failed");
|
||||
|
||||
// Verify: SingleOrg enabled, AutoConfirm attempted, SingleOrg rolled back
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(3);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockOrgId,
|
||||
PolicyType.AutoConfirm,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: false, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not rollback SingleOrg policy when AutoConfirm fails but SingleOrg was already enabled", async () => {
|
||||
const autoConfirmError = new Error("AutoConfirm failed");
|
||||
|
||||
// AutoConfirm call fails (SingleOrg was already enabled, so no SingleOrg calls)
|
||||
mockPolicyApiService.putPolicyVNext.mockRejectedValue(autoConfirmError);
|
||||
|
||||
await expect(component["handleSubmit"](true)).rejects.toThrow("AutoConfirm failed");
|
||||
|
||||
// Verify only AutoConfirm was called (no SingleOrg enable/rollback)
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
PolicyType.AutoConfirm,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep both policies enabled when both submissions succeed", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
await component["handleSubmit"](false);
|
||||
|
||||
// Verify two calls: SingleOrg enable and AutoConfirm enable
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(2);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockOrgId,
|
||||
PolicyType.AutoConfirm,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should re-throw the error after rollback", async () => {
|
||||
const autoConfirmError = new Error("Network error");
|
||||
|
||||
mockPolicyApiService.putPolicyVNext
|
||||
.mockResolvedValueOnce({} as any) // SingleOrg enable
|
||||
.mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails
|
||||
.mockResolvedValueOnce({} as any); // SingleOrg rollback
|
||||
|
||||
await expect(component["handleSubmit"](false)).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSingleOrgPolicy", () => {
|
||||
it("should call putPolicyVNext with enabled: true when enabling", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
await component["setSingleOrgPolicy"](true);
|
||||
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should call putPolicyVNext with enabled: false when disabling", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
await component["setSingleOrgPolicy"](false);
|
||||
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: false, data: null } },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -181,10 +181,21 @@ export class AutoConfirmPolicyDialogComponent
|
||||
}
|
||||
|
||||
private async handleSubmit(singleOrgEnabled: boolean) {
|
||||
if (!singleOrgEnabled) {
|
||||
await this.submitSingleOrg();
|
||||
const enabledSingleOrgDuringAction = !singleOrgEnabled;
|
||||
|
||||
if (enabledSingleOrgDuringAction) {
|
||||
await this.setSingleOrgPolicy(true);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.submitAutoConfirm();
|
||||
} catch (error) {
|
||||
// Roll back SingleOrg if we enabled it during this action
|
||||
if (enabledSingleOrgDuringAction) {
|
||||
await this.setSingleOrgPolicy(false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.submitAutoConfirm();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,11 +209,9 @@ export class AutoConfirmPolicyDialogComponent
|
||||
|
||||
const autoConfirmRequest = await this.policyComponent.buildRequest();
|
||||
|
||||
await this.policyApiService.putPolicy(
|
||||
this.data.organizationId,
|
||||
this.data.policy.type,
|
||||
autoConfirmRequest,
|
||||
);
|
||||
await this.policyApiService.putPolicyVNext(this.data.organizationId, this.data.policy.type, {
|
||||
policy: autoConfirmRequest,
|
||||
});
|
||||
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
@@ -225,17 +234,15 @@ export class AutoConfirmPolicyDialogComponent
|
||||
}
|
||||
}
|
||||
|
||||
private async submitSingleOrg(): Promise<void> {
|
||||
private async setSingleOrgPolicy(enabled: boolean): Promise<void> {
|
||||
const singleOrgRequest: PolicyRequest = {
|
||||
enabled: true,
|
||||
enabled,
|
||||
data: null,
|
||||
};
|
||||
|
||||
await this.policyApiService.putPolicyVNext(
|
||||
this.data.organizationId,
|
||||
PolicyType.SingleOrg,
|
||||
singleOrgRequest,
|
||||
);
|
||||
await this.policyApiService.putPolicyVNext(this.data.organizationId, PolicyType.SingleOrg, {
|
||||
policy: singleOrgRequest,
|
||||
});
|
||||
}
|
||||
|
||||
private async openBrowserExtension() {
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</bit-tab>
|
||||
<bit-tab label="{{ 'access' | i18n }}">
|
||||
<bit-tab [label]="accessTabLabel">
|
||||
<div class="tw-mb-3">
|
||||
<ng-container *ngIf="dialogReadonly">
|
||||
<span>{{ "readOnlyCollectionAccess" | i18n }}</span>
|
||||
|
||||
@@ -361,6 +361,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
||||
return this.params.readonly === true;
|
||||
}
|
||||
|
||||
protected get accessTabLabel(): string {
|
||||
return this.dialogReadonly
|
||||
? this.i18nService.t("viewAccess")
|
||||
: this.i18nService.t("editAccess");
|
||||
}
|
||||
|
||||
protected async cancel() {
|
||||
this.close(CollectionDialogAction.Canceled);
|
||||
}
|
||||
|
||||
@@ -57,12 +57,8 @@
|
||||
<ng-container *ngIf="subscription">
|
||||
<ng-container *ngIf="enableDiscountDisplay$ | async as enableDiscount; else noDiscount">
|
||||
<div class="tw-flex tw-items-center tw-gap-2 tw-flex-wrap tw-justify-end">
|
||||
<span [attr.aria-label]="'nextChargeDateAndAmount' | i18n">
|
||||
{{
|
||||
(sub.subscription.periodEndDate | date: "MMM d, y") +
|
||||
", " +
|
||||
(discountedSubscriptionAmount | currency: "$")
|
||||
}}
|
||||
<span [attr.aria-label]="'nextChargeDate' | i18n">
|
||||
{{ sub.subscription.periodEndDate | date: "MMM d, y" }}
|
||||
</span>
|
||||
<billing-discount-badge
|
||||
[discount]="getDiscount(sub?.customerDiscount)"
|
||||
@@ -71,12 +67,8 @@
|
||||
</ng-container>
|
||||
<ng-template #noDiscount>
|
||||
<div class="tw-flex tw-items-center tw-gap-2 tw-flex-wrap tw-justify-end">
|
||||
<span [attr.aria-label]="'nextChargeDateAndAmount' | i18n">
|
||||
{{
|
||||
(sub.subscription.periodEndDate | date: "MMM d, y") +
|
||||
", " +
|
||||
(subscriptionAmount | currency: "$")
|
||||
}}
|
||||
<span [attr.aria-label]="'nextChargeDate' | i18n">
|
||||
{{ sub.subscription.periodEndDate | date: "MMM d, y" }}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -722,6 +722,8 @@ export class EventService {
|
||||
return ["bwi-browser", this.i18nService.t("webVault") + " - Edge"];
|
||||
case DeviceType.IEBrowser:
|
||||
return ["bwi-browser", this.i18nService.t("webVault") + " - IE"];
|
||||
case DeviceType.DuckDuckGoBrowser:
|
||||
return ["bwi-browser", this.i18nService.t("webVault") + " - DuckDuckGo"];
|
||||
case DeviceType.Server:
|
||||
return ["bwi-user-monitor", this.i18nService.t("server")];
|
||||
case DeviceType.WindowsCLI:
|
||||
|
||||
@@ -12,7 +12,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import {
|
||||
CenterPositionStrategy,
|
||||
@@ -148,11 +147,16 @@ export class BulkDeleteDialogComponent {
|
||||
}
|
||||
|
||||
private async deleteCiphersAdmin(ciphers: string[]): Promise<any> {
|
||||
const deleteRequest = new CipherBulkDeleteRequest(ciphers, this.organization.id);
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
if (this.permanent) {
|
||||
return await this.apiService.deleteManyCiphersAdmin(deleteRequest);
|
||||
await this.cipherService.deleteManyWithServer(ciphers, userId, true, this.organization.id);
|
||||
} else {
|
||||
return await this.apiService.putDeleteManyCiphersAdmin(deleteRequest);
|
||||
await this.cipherService.softDeleteManyWithServer(
|
||||
ciphers,
|
||||
userId,
|
||||
true,
|
||||
this.organization.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3281,6 +3281,9 @@
|
||||
"nextChargeHeader": {
|
||||
"message": "Next Charge"
|
||||
},
|
||||
"nextChargeDate": {
|
||||
"message": "Next charge date"
|
||||
},
|
||||
"plan": {
|
||||
"message": "Plan"
|
||||
},
|
||||
@@ -6928,8 +6931,8 @@
|
||||
"activateAutofill": {
|
||||
"message": "Activate auto-fill"
|
||||
},
|
||||
"activateAutofillPolicyDesc": {
|
||||
"message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members."
|
||||
"activateAutofillPolicyDescription": {
|
||||
"message": "Activate the autofill on page load setting on the browser extension for all existing and new members."
|
||||
},
|
||||
"experimentalFeature": {
|
||||
"message": "Compromised or untrusted websites can exploit auto-fill on page load."
|
||||
@@ -11366,6 +11369,18 @@
|
||||
"automaticDomainClaimProcess": {
|
||||
"message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed."
|
||||
},
|
||||
"automaticDomainClaimProcess1": {
|
||||
"message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days."
|
||||
},
|
||||
"automaticDomainClaimProcess2": {
|
||||
"message": "Once claimed, existing members with claimed domains will be emailed about the "
|
||||
},
|
||||
"accountOwnershipChange": {
|
||||
"message": "account ownership change"
|
||||
},
|
||||
"automaticDomainClaimProcessEnd": {
|
||||
"message": "."
|
||||
},
|
||||
"domainNotClaimed": {
|
||||
"message": "$DOMAIN$ not claimed. Check your DNS records.",
|
||||
"placeholders": {
|
||||
@@ -11378,8 +11393,8 @@
|
||||
"domainStatusClaimed": {
|
||||
"message": "Claimed"
|
||||
},
|
||||
"domainStatusUnderVerification": {
|
||||
"message": "Under verification"
|
||||
"domainStatusPending": {
|
||||
"message": "Pending"
|
||||
},
|
||||
"claimedDomainsDescription": {
|
||||
"message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts."
|
||||
|
||||
@@ -319,7 +319,7 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
https://*.paypal.com
|
||||
https://www.paypalobjects.com
|
||||
https://q.stripe.com
|
||||
https://haveibeenpwned.com
|
||||
https://logos.haveibeenpwned.com
|
||||
;media-src
|
||||
'self'
|
||||
https://assets.bitwarden.com
|
||||
|
||||
@@ -10,22 +10,34 @@
|
||||
{{ "claimDomain" | i18n }}
|
||||
</span>
|
||||
|
||||
<span *ngIf="data.orgDomain" class="tw-text-xs tw-text-muted">
|
||||
{{ data.orgDomain.domainName }}
|
||||
</span>
|
||||
|
||||
<span *ngIf="data?.orgDomain && !data.orgDomain?.verifiedDate" bitBadge variant="warning">
|
||||
{{ "domainStatusUnderVerification" | i18n }}
|
||||
{{ "domainStatusPending" | i18n }}
|
||||
</span>
|
||||
<span *ngIf="data?.orgDomain && data?.orgDomain?.verifiedDate" bitBadge variant="success">
|
||||
{{ "domainStatusClaimed" | i18n }}
|
||||
</span>
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<div *ngIf="data?.orgDomain && !data?.orgDomain?.verifiedDate" class="tw-space-y-2 tw-pb-4">
|
||||
<p bitTypography="body1">{{ "automaticDomainClaimProcess1" | i18n }}</p>
|
||||
<p bitTypography="body1">
|
||||
{{ "automaticDomainClaimProcess2" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://bitwarden.com/help/claimed-accounts/"
|
||||
class="tw-inline-flex tw-items-center tw-gap-1"
|
||||
>
|
||||
{{ "accountOwnershipChange" | i18n }}
|
||||
<i class="bwi bwi-external-link tw-text-xs" aria-hidden="true"></i>
|
||||
</a>
|
||||
{{ "automaticDomainClaimProcessEnd" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "domainName" | i18n }}</bit-label>
|
||||
<input bitInput appAutofocus formControlName="domainName" [showErrorsWhenDisabled]="true" />
|
||||
<bit-hint>{{ "claimDomainNameInputHint" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field *ngIf="data?.orgDomain">
|
||||
@@ -40,14 +52,6 @@
|
||||
(click)="copyDnsTxt()"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-callout
|
||||
*ngIf="data?.orgDomain && !data?.orgDomain?.verifiedDate"
|
||||
type="info"
|
||||
title="{{ 'automaticClaimedDomains' | i18n | uppercase }}"
|
||||
>
|
||||
{{ "automaticDomainClaimProcess" | i18n }}
|
||||
</bit-callout>
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary">
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</td>
|
||||
<td bitCell>
|
||||
<span *ngIf="!orgDomain?.verifiedDate" bitBadge variant="warning">{{
|
||||
"domainStatusUnderVerification" | i18n
|
||||
"domainStatusPending" | i18n
|
||||
}}</span>
|
||||
<span *ngIf="orgDomain?.verifiedDate" bitBadge variant="success">{{
|
||||
"domainStatusClaimed" | i18n
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
export class ActivateAutofillPolicy extends BasePolicyEditDefinition {
|
||||
name = "activateAutofill";
|
||||
description = "activateAutofillPolicyDesc";
|
||||
description = "activateAutofillPolicyDescription";
|
||||
type = PolicyType.ActivateAutofill;
|
||||
component = ActivateAutofillPolicyComponent;
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
|
||||
this.getNudgeStatus$(nudgeType, userId),
|
||||
of(Date.now() - THIRTY_DAYS_MS),
|
||||
from(this.pinService.isPinSet(userId)),
|
||||
this.biometricStateService.biometricUnlockEnabled$,
|
||||
this.biometricStateService.biometricUnlockEnabled$(userId),
|
||||
this.organizationService.organizations$(userId),
|
||||
this.policyService.policiesByType$(PolicyType.RemoveUnlockWithPin, userId),
|
||||
]).pipe(
|
||||
|
||||
@@ -28,6 +28,41 @@ export const EVENTS = {
|
||||
SUBMIT: "submit",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* HTML attributes observed by the MutationObserver for autofill form/field tracking.
|
||||
* If you need to observe a new attribute, add it here.
|
||||
*/
|
||||
export const AUTOFILL_ATTRIBUTES = {
|
||||
ACTION: "action",
|
||||
ARIA_DESCRIBEDBY: "aria-describedby",
|
||||
ARIA_DISABLED: "aria-disabled",
|
||||
ARIA_HASPOPUP: "aria-haspopup",
|
||||
ARIA_HIDDEN: "aria-hidden",
|
||||
ARIA_LABEL: "aria-label",
|
||||
ARIA_LABELLEDBY: "aria-labelledby",
|
||||
AUTOCOMPLETE: "autocomplete",
|
||||
AUTOCOMPLETE_TYPE: "autocompletetype",
|
||||
X_AUTOCOMPLETE_TYPE: "x-autocompletetype",
|
||||
CHECKED: "checked",
|
||||
CLASS: "class",
|
||||
DATA_LABEL: "data-label",
|
||||
DATA_STRIPE: "data-stripe",
|
||||
DISABLED: "disabled",
|
||||
ID: "id",
|
||||
MAXLENGTH: "maxlength",
|
||||
METHOD: "method",
|
||||
NAME: "name",
|
||||
PLACEHOLDER: "placeholder",
|
||||
POPOVER: "popover",
|
||||
POPOVERTARGET: "popovertarget",
|
||||
POPOVERTARGETACTION: "popovertargetaction",
|
||||
READONLY: "readonly",
|
||||
REL: "rel",
|
||||
TABINDEX: "tabindex",
|
||||
TITLE: "title",
|
||||
TYPE: "type",
|
||||
} as const;
|
||||
|
||||
export const ClearClipboardDelay = {
|
||||
Never: null as null,
|
||||
TenSeconds: 10,
|
||||
|
||||
@@ -18,6 +18,7 @@ export enum FeatureFlag {
|
||||
|
||||
/* Auth */
|
||||
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
|
||||
SafariAccountSwitching = "pm-5594-safari-account-switching",
|
||||
|
||||
/* Autofill */
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
@@ -133,6 +134,7 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
[FeatureFlag.SafariAccountSwitching]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
|
||||
@@ -11,6 +11,20 @@ import { PinLockType } from "./pin-lock-type";
|
||||
* The PinStateService manages the storage and retrieval of PIN-related state for user accounts.
|
||||
*/
|
||||
export abstract class PinStateServiceAbstraction {
|
||||
/**
|
||||
* Checks if a user is enrolled into PIN unlock
|
||||
* @param userId The user's id
|
||||
* @throws If the user id is not provided
|
||||
*/
|
||||
abstract pinSet$(userId: UserId): Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Gets the user's {@link PinLockType}
|
||||
* @param userId The user's id
|
||||
* @throws If the user id is not provided
|
||||
*/
|
||||
abstract pinLockType$(userId: UserId): Observable<PinLockType>;
|
||||
|
||||
/**
|
||||
* Gets the user's UserKey encrypted PIN
|
||||
* @deprecated - This is not a public API. DO NOT USE IT
|
||||
@@ -21,17 +35,12 @@ export abstract class PinStateServiceAbstraction {
|
||||
|
||||
/**
|
||||
* Gets the user's {@link PinLockType}
|
||||
* @deprecated Use {@link pinLockType$} instead
|
||||
* @param userId The user's id
|
||||
* @throws If the user id is not provided
|
||||
*/
|
||||
abstract getPinLockType(userId: UserId): Promise<PinLockType>;
|
||||
|
||||
/**
|
||||
* Checks if a user is enrolled into PIN unlock
|
||||
* @param userId The user's id
|
||||
*/
|
||||
abstract isPinSet(userId: UserId): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Gets the user's PIN-protected UserKey envelope, either persistent or ephemeral based on the provided PinLockType
|
||||
* @deprecated - This is not a public API. DO NOT USE IT
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { firstValueFrom, map, Observable } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
@@ -26,27 +26,36 @@ export class PinStateService implements PinStateServiceAbstraction {
|
||||
.pipe(map((value) => (value ? new EncString(value) : null)));
|
||||
}
|
||||
|
||||
async isPinSet(userId: UserId): Promise<boolean> {
|
||||
pinSet$(userId: UserId): Observable<boolean> {
|
||||
assertNonNullish(userId, "userId");
|
||||
return (await this.getPinLockType(userId)) !== "DISABLED";
|
||||
return this.pinLockType$(userId).pipe(map((pinLockType) => pinLockType !== "DISABLED"));
|
||||
}
|
||||
|
||||
pinLockType$(userId: UserId): Observable<PinLockType> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
return combineLatest([
|
||||
this.pinProtectedUserKeyEnvelope$(userId, "PERSISTENT").pipe(map((key) => key != null)),
|
||||
this.stateProvider
|
||||
.getUserState$(USER_KEY_ENCRYPTED_PIN, userId)
|
||||
.pipe(map((key) => key != null)),
|
||||
]).pipe(
|
||||
map(([isPersistentPinSet, isPinSet]) => {
|
||||
if (isPersistentPinSet) {
|
||||
return "PERSISTENT";
|
||||
} else if (isPinSet) {
|
||||
return "EPHEMERAL";
|
||||
} else {
|
||||
return "DISABLED";
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getPinLockType(userId: UserId): Promise<PinLockType> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
const isPersistentPinSet =
|
||||
(await this.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null;
|
||||
const isPinSet =
|
||||
(await firstValueFrom(this.stateProvider.getUserState$(USER_KEY_ENCRYPTED_PIN, userId))) !=
|
||||
null;
|
||||
|
||||
if (isPersistentPinSet) {
|
||||
return "PERSISTENT";
|
||||
} else if (isPinSet) {
|
||||
return "EPHEMERAL";
|
||||
} else {
|
||||
return "DISABLED";
|
||||
}
|
||||
return await firstValueFrom(this.pinLockType$(userId));
|
||||
}
|
||||
|
||||
async getPinProtectedUserKeyEnvelope(
|
||||
@@ -55,17 +64,7 @@ export class PinStateService implements PinStateServiceAbstraction {
|
||||
): Promise<PasswordProtectedKeyEnvelope | null> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
if (pinLockType === "EPHEMERAL") {
|
||||
return await firstValueFrom(
|
||||
this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, userId),
|
||||
);
|
||||
} else if (pinLockType === "PERSISTENT") {
|
||||
return await firstValueFrom(
|
||||
this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, userId),
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Unsupported PinLockType: ${pinLockType}`);
|
||||
}
|
||||
return await firstValueFrom(this.pinProtectedUserKeyEnvelope$(userId, pinLockType));
|
||||
}
|
||||
|
||||
async setPinState(
|
||||
@@ -110,4 +109,19 @@ export class PinStateService implements PinStateServiceAbstraction {
|
||||
|
||||
await this.stateProvider.setUserState(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, null, userId);
|
||||
}
|
||||
|
||||
private pinProtectedUserKeyEnvelope$(
|
||||
userId: UserId,
|
||||
pinLockType: PinLockType,
|
||||
): Observable<PasswordProtectedKeyEnvelope | null> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
if (pinLockType === "EPHEMERAL") {
|
||||
return this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, userId);
|
||||
} else if (pinLockType === "PERSISTENT") {
|
||||
return this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, userId);
|
||||
} else {
|
||||
throw new Error(`Unsupported PinLockType: ${pinLockType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal";
|
||||
|
||||
@@ -94,14 +94,50 @@ describe("PinStateService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPinLockType()", () => {
|
||||
describe("pinSet$", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should throw an error if userId is null", async () => {
|
||||
// Act & Assert
|
||||
await expect(sut.getPinLockType(null as any)).rejects.toThrow("userId");
|
||||
expect(() => sut.pinSet$(null as any)).toThrow("userId");
|
||||
});
|
||||
|
||||
it("should return false when pin lock type is DISABLED", async () => {
|
||||
// Arrange
|
||||
jest.spyOn(sut, "pinLockType$").mockReturnValue(of("DISABLED"));
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(sut.pinSet$(mockUserId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it.each([["PERSISTENT" as PinLockType], ["EPHEMERAL" as PinLockType]])(
|
||||
"should return true when pin lock type is %s",
|
||||
async (pinLockType) => {
|
||||
// Arrange
|
||||
jest.spyOn(sut, "pinLockType$").mockReturnValue(of(pinLockType));
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(sut.pinSet$(mockUserId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("pinLockType$", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should throw an error if userId is null", async () => {
|
||||
// Act & Assert
|
||||
expect(() => sut.pinLockType$(null as any)).toThrow("userId");
|
||||
});
|
||||
|
||||
it("should return 'PERSISTENT' if a pin protected user key (persistent) is found", async () => {
|
||||
@@ -114,7 +150,7 @@ describe("PinStateService", () => {
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await sut.getPinLockType(mockUserId);
|
||||
const result = await firstValueFrom(sut.pinLockType$(mockUserId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("PERSISTENT");
|
||||
@@ -125,7 +161,7 @@ describe("PinStateService", () => {
|
||||
await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, mockUserKeyEncryptedPin, mockUserId);
|
||||
|
||||
// Act
|
||||
const result = await sut.getPinLockType(mockUserId);
|
||||
const result = await firstValueFrom(sut.pinLockType$(mockUserId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("EPHEMERAL");
|
||||
@@ -135,7 +171,7 @@ describe("PinStateService", () => {
|
||||
// Arrange - don't set any PIN-related state
|
||||
|
||||
// Act
|
||||
const result = await sut.getPinLockType(mockUserId);
|
||||
const result = await firstValueFrom(sut.pinLockType$(mockUserId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("DISABLED");
|
||||
@@ -151,7 +187,7 @@ describe("PinStateService", () => {
|
||||
await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, mockUserId);
|
||||
|
||||
// Act
|
||||
const result = await sut.getPinLockType(mockUserId);
|
||||
const result = await firstValueFrom(sut.pinLockType$(mockUserId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("DISABLED");
|
||||
|
||||
@@ -20,10 +20,9 @@ export abstract class VaultTimeoutSettingsService {
|
||||
/**
|
||||
* Get the available vault timeout actions for the current user
|
||||
*
|
||||
* **NOTE:** This observable is not yet connected to the state service, so it will not update when the state changes
|
||||
* @param userId The user id to check. If not provided, the current user is used
|
||||
*/
|
||||
abstract availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]>;
|
||||
abstract availableVaultTimeoutActions$(userId?: UserId): Observable<VaultTimeoutAction[]>;
|
||||
|
||||
/**
|
||||
* Evaluates the user's available vault timeout actions and returns a boolean representing
|
||||
@@ -55,5 +54,5 @@ export abstract class VaultTimeoutSettingsService {
|
||||
* @param userId The user id to check. If not provided, the current user is used
|
||||
* @returns boolean true if biometric lock is set
|
||||
*/
|
||||
abstract isBiometricLockSet(userId?: string): Promise<boolean>;
|
||||
abstract isBiometricLockSet(userId?: UserId): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
|
||||
vaultTimeoutSettingsService = createVaultTimeoutSettingsService(defaultVaultTimeout);
|
||||
|
||||
biometricStateService.biometricUnlockEnabled$ = of(false);
|
||||
pinStateService.pinSet$.mockReturnValue(of(false));
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,72 +87,121 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
});
|
||||
|
||||
describe("availableVaultTimeoutActions$", () => {
|
||||
it("always returns LogOut", async () => {
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
describe("when no userId provided (active user)", () => {
|
||||
it("always returns LogOut", async () => {
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has a master password", async () => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(userDecryptionOptionsService.hasMasterPasswordById$).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
);
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => {
|
||||
pinStateService.pinSet$.mockReturnValue(of(true));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has biometrics configured", async () => {
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true));
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false }));
|
||||
pinStateService.pinSet$.mockReturnValue(of(false));
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).not.toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("should throw error when activeAccount$ is null", async () => {
|
||||
accountService.activeAccountSubject.next(null);
|
||||
|
||||
const result$ = vaultTimeoutSettingsService.availableVaultTimeoutActions$();
|
||||
|
||||
await expect(firstValueFrom(result$)).rejects.toThrow("Null or undefined account");
|
||||
});
|
||||
});
|
||||
|
||||
it("contains Lock when the user has a master password", async () => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
describe("with explicit userId parameter", () => {
|
||||
it("should return Lock and LogOut when provided user has master password", async () => {
|
||||
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
expect(userDecryptionOptionsService.hasMasterPasswordById$).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
);
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => {
|
||||
pinStateService.isPinSet.mockResolvedValue(true);
|
||||
it("should return Lock and LogOut when provided user has PIN configured", async () => {
|
||||
pinStateService.pinSet$.mockReturnValue(of(true));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
expect(pinStateService.pinSet$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has biometrics configured", async () => {
|
||||
biometricStateService.biometricUnlockEnabled$ = of(true);
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
|
||||
it("should return Lock and LogOut when provided user has biometrics configured", async () => {
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
expect(biometricStateService.biometricUnlockEnabled$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
});
|
||||
|
||||
it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false }));
|
||||
pinStateService.isPinSet.mockResolvedValue(false);
|
||||
biometricStateService.biometricUnlockEnabled$ = of(false);
|
||||
it("should not return Lock when provided user has no unlock methods", async () => {
|
||||
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
|
||||
pinStateService.pinSet$.mockReturnValue(of(false));
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).not.toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("should return only LogOut when userId is not provided and there is no active account", async () => {
|
||||
// Set up accountService to return null for activeAccount
|
||||
accountService.activeAccount$ = of(null);
|
||||
pinStateService.isPinSet.mockResolvedValue(false);
|
||||
biometricStateService.biometricUnlockEnabled$ = of(false);
|
||||
|
||||
// Call availableVaultTimeoutActions$ which internally calls userHasMasterPassword without a userId
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
// Since there's no active account, userHasMasterPassword returns false,
|
||||
// meaning no master password is available, so Lock should not be available
|
||||
expect(result).toEqual([VaultTimeoutAction.LogOut]);
|
||||
expect(result).not.toContain(VaultTimeoutAction.Lock);
|
||||
expect(result).not.toContain(VaultTimeoutAction.Lock);
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,8 +287,8 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
`(
|
||||
"returns $expected when policy is $policy, has PIN unlock method: $hasPinUnlock or Biometric unlock method: $hasBiometricUnlock, and user preference is $userPreference",
|
||||
async ({ hasPinUnlock, hasBiometricUnlock, policy, userPreference, expected }) => {
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(hasBiometricUnlock);
|
||||
pinStateService.isPinSet.mockResolvedValue(hasPinUnlock);
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(hasBiometricUnlock));
|
||||
pinStateService.pinSet$.mockReturnValue(of(hasPinUnlock));
|
||||
|
||||
userDecryptionOptionsSubject.next(
|
||||
new UserDecryptionOptions({ hasMasterPassword: false }),
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
defer,
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
of,
|
||||
Observable,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
concatMap,
|
||||
} from "rxjs";
|
||||
|
||||
@@ -28,6 +27,7 @@ import { PolicyType } from "../../../admin-console/enums";
|
||||
import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { getUserId } from "../../../auth/services/account.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -101,8 +101,29 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
await this.keyService.refreshAdditionalKeys(userId);
|
||||
}
|
||||
|
||||
availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]> {
|
||||
return defer(() => this.getAvailableVaultTimeoutActions(userId));
|
||||
availableVaultTimeoutActions$(userId?: UserId): Observable<VaultTimeoutAction[]> {
|
||||
const userId$ =
|
||||
userId != null
|
||||
? of(userId)
|
||||
: // TODO remove with https://bitwarden.atlassian.net/browse/PM-10647
|
||||
getUserId(this.accountService.activeAccount$);
|
||||
|
||||
return userId$.pipe(
|
||||
switchMap((userId) =>
|
||||
combineLatest([
|
||||
this.userDecryptionOptionsService.hasMasterPasswordById$(userId),
|
||||
this.biometricStateService.biometricUnlockEnabled$(userId),
|
||||
this.pinStateService.pinSet$(userId),
|
||||
]),
|
||||
),
|
||||
map(([haveMasterPassword, biometricUnlockEnabled, isPinSet]) => {
|
||||
const canLock = haveMasterPassword || biometricUnlockEnabled || isPinSet;
|
||||
if (canLock) {
|
||||
return [VaultTimeoutAction.LogOut, VaultTimeoutAction.Lock];
|
||||
}
|
||||
return [VaultTimeoutAction.LogOut];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async canLock(userId: UserId): Promise<boolean> {
|
||||
@@ -112,12 +133,8 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
return availableVaultTimeoutActions?.includes(VaultTimeoutAction.Lock) || false;
|
||||
}
|
||||
|
||||
async isBiometricLockSet(userId?: string): Promise<boolean> {
|
||||
const biometricUnlockPromise =
|
||||
userId == null
|
||||
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
|
||||
: this.biometricStateService.getBiometricUnlockEnabled(userId as UserId);
|
||||
return await biometricUnlockPromise;
|
||||
async isBiometricLockSet(userId?: UserId): Promise<boolean> {
|
||||
return await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$(userId));
|
||||
}
|
||||
|
||||
private async setVaultTimeout(userId: UserId, timeout: VaultTimeout): Promise<void> {
|
||||
@@ -262,45 +279,45 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
return combineLatest([
|
||||
this.stateProvider.getUserState$(VAULT_TIMEOUT_ACTION, userId),
|
||||
this.getMaxSessionTimeoutPolicyDataByUserId$(userId),
|
||||
this.availableVaultTimeoutActions$(userId),
|
||||
]).pipe(
|
||||
switchMap(([currentVaultTimeoutAction, maxSessionTimeoutPolicyData]) => {
|
||||
return from(
|
||||
this.determineVaultTimeoutAction(
|
||||
userId,
|
||||
concatMap(
|
||||
async ([
|
||||
currentVaultTimeoutAction,
|
||||
maxSessionTimeoutPolicyData,
|
||||
availableVaultTimeoutActions,
|
||||
]) => {
|
||||
const vaultTimeoutAction = this.determineVaultTimeoutAction(
|
||||
availableVaultTimeoutActions,
|
||||
currentVaultTimeoutAction,
|
||||
maxSessionTimeoutPolicyData,
|
||||
),
|
||||
).pipe(
|
||||
tap((vaultTimeoutAction: VaultTimeoutAction) => {
|
||||
// As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current
|
||||
// We want to avoid having a null timeout action always so we set it to the default if it is null
|
||||
// and if the user becomes subject to a policy that requires a specific action, we set it to that
|
||||
if (vaultTimeoutAction !== currentVaultTimeoutAction) {
|
||||
return this.stateProvider.setUserState(
|
||||
VAULT_TIMEOUT_ACTION,
|
||||
vaultTimeoutAction,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
// Protect outer observable from canceling on error by catching and returning EMPTY
|
||||
this.logService.error(`Error getting vault timeout: ${error}`);
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
);
|
||||
|
||||
// As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current
|
||||
// We want to avoid having a null timeout action always so we set it to the default if it is null
|
||||
// and if the user becomes subject to a policy that requires a specific action, we set it to that
|
||||
if (vaultTimeoutAction !== currentVaultTimeoutAction) {
|
||||
await this.stateProvider.setUserState(VAULT_TIMEOUT_ACTION, vaultTimeoutAction, userId);
|
||||
}
|
||||
|
||||
return vaultTimeoutAction;
|
||||
},
|
||||
),
|
||||
catchError((error: unknown) => {
|
||||
// Protect outer observable from canceling on error by catching and returning EMPTY
|
||||
this.logService.error(`Error getting vault timeout: ${error}`);
|
||||
return EMPTY;
|
||||
}),
|
||||
distinctUntilChanged(), // Avoid having the set side effect trigger a new emission of the same action
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
}
|
||||
|
||||
private async determineVaultTimeoutAction(
|
||||
userId: string,
|
||||
private determineVaultTimeoutAction(
|
||||
availableVaultTimeoutActions: VaultTimeoutAction[],
|
||||
currentVaultTimeoutAction: VaultTimeoutAction | null,
|
||||
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
|
||||
): Promise<VaultTimeoutAction> {
|
||||
const availableVaultTimeoutActions = await this.getAvailableVaultTimeoutActions(userId);
|
||||
): VaultTimeoutAction {
|
||||
if (availableVaultTimeoutActions.length === 1) {
|
||||
return availableVaultTimeoutActions[0];
|
||||
}
|
||||
@@ -339,38 +356,4 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null),
|
||||
);
|
||||
}
|
||||
|
||||
private async getAvailableVaultTimeoutActions(userId?: string): Promise<VaultTimeoutAction[]> {
|
||||
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
|
||||
const availableActions = [VaultTimeoutAction.LogOut];
|
||||
|
||||
const canLock =
|
||||
(await this.userHasMasterPassword(userId)) ||
|
||||
(await this.pinStateService.isPinSet(userId as UserId)) ||
|
||||
(await this.isBiometricLockSet(userId));
|
||||
|
||||
if (canLock) {
|
||||
availableActions.push(VaultTimeoutAction.Lock);
|
||||
}
|
||||
|
||||
return availableActions;
|
||||
}
|
||||
|
||||
private async userHasMasterPassword(userId: string): Promise<boolean> {
|
||||
let resolvedUserId: UserId;
|
||||
if (userId) {
|
||||
resolvedUserId = userId as UserId;
|
||||
} else {
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (!activeAccount) {
|
||||
return false; // No account, can't have master password
|
||||
}
|
||||
resolvedUserId = activeAccount.id;
|
||||
}
|
||||
|
||||
return await firstValueFrom(
|
||||
this.userDecryptionOptionsService.hasMasterPasswordById$(resolvedUserId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
/**
|
||||
@@ -34,4 +34,76 @@ export abstract class CipherSdkService {
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView | undefined>;
|
||||
|
||||
/**
|
||||
* Deletes a cipher on the server using the SDK.
|
||||
*
|
||||
* @param id The cipher ID to delete
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param asAdmin Whether this is an organization admin operation
|
||||
* @returns A promise that resolves when the cipher is deleted
|
||||
*/
|
||||
abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes multiple ciphers on the server using the SDK.
|
||||
*
|
||||
* @param ids The cipher IDs to delete
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param asAdmin Whether this is an organization admin operation
|
||||
* @param orgId The organization ID (required when asAdmin is true)
|
||||
* @returns A promise that resolves when the ciphers are deleted
|
||||
*/
|
||||
abstract deleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin?: boolean,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Soft deletes a cipher on the server using the SDK.
|
||||
*
|
||||
* @param id The cipher ID to soft delete
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param asAdmin Whether this is an organization admin operation
|
||||
* @returns A promise that resolves when the cipher is soft deleted
|
||||
*/
|
||||
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Soft deletes multiple ciphers on the server using the SDK.
|
||||
*
|
||||
* @param ids The cipher IDs to soft delete
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param asAdmin Whether this is an organization admin operation
|
||||
* @param orgId The organization ID (required when asAdmin is true)
|
||||
* @returns A promise that resolves when the ciphers are soft deleted
|
||||
*/
|
||||
abstract softDeleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin?: boolean,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Restores a soft-deleted cipher on the server using the SDK.
|
||||
*
|
||||
* @param id The cipher ID to restore
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param asAdmin Whether this is an organization admin operation
|
||||
* @returns A promise that resolves when the cipher is restored
|
||||
*/
|
||||
abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Restores multiple soft-deleted ciphers on the server using the SDK.
|
||||
*
|
||||
* @param ids The cipher IDs to restore
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param orgId The organization ID (determines whether to use admin API)
|
||||
* @returns A promise that resolves when the ciphers are restored
|
||||
*/
|
||||
abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -230,8 +230,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
abstract clear(userId?: string): Promise<void>;
|
||||
abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise<any>;
|
||||
abstract delete(id: string | string[], userId: UserId): Promise<any>;
|
||||
abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
abstract deleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
abstract deleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin?: boolean,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void>;
|
||||
abstract deleteAttachment(
|
||||
id: string,
|
||||
revisionDate: string,
|
||||
@@ -247,14 +252,19 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
abstract sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number;
|
||||
abstract sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number;
|
||||
abstract getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number;
|
||||
abstract softDelete(id: string | string[], userId: UserId): Promise<any>;
|
||||
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
abstract softDelete(id: string | string[], userId: UserId): Promise<void>;
|
||||
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
abstract softDeleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin?: boolean,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void>;
|
||||
abstract restore(
|
||||
cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[],
|
||||
userId: UserId,
|
||||
): Promise<any>;
|
||||
abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
): Promise<void>;
|
||||
abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void>;
|
||||
abstract getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise<any>;
|
||||
abstract setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId): Promise<void>;
|
||||
@@ -275,7 +285,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
abstract getNextIdentityCipher(userId: UserId): Promise<CipherView>;
|
||||
|
||||
/**
|
||||
* Decrypts a cipher using either the SDK or the legacy method based on the feature flag.
|
||||
* Decrypts a cipher using either the use-sdk-cipheroperationsSDK or the legacy method based on the feature flag.
|
||||
* @param cipher The cipher to decrypt.
|
||||
* @param userId The user ID to use for decryption.
|
||||
* @returns A promise that resolves to the decrypted cipher view.
|
||||
|
||||
@@ -16,11 +16,6 @@ export abstract class VaultSettingsService {
|
||||
* An observable monitoring the state of the show identities on the current tab.
|
||||
*/
|
||||
abstract showIdentitiesCurrentTab$: Observable<boolean>;
|
||||
/**
|
||||
* An observable monitoring the state of the click items on the Vault view
|
||||
* for Autofill suggestions.
|
||||
*/
|
||||
abstract clickItemsToAutofillVaultView$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Saves the enable passkeys setting to disk.
|
||||
@@ -37,10 +32,4 @@ export abstract class VaultSettingsService {
|
||||
* @param value The new value for the show identities on tab page setting.
|
||||
*/
|
||||
abstract setShowIdentitiesCurrentTab(value: boolean): Promise<void>;
|
||||
/**
|
||||
* Saves the click items on vault View for Autofill suggestions to disk.
|
||||
* @param value The new value for the click items on vault View for
|
||||
* Autofill suggestions setting.
|
||||
*/
|
||||
abstract setClickItemsToAutofillVaultView(value: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -28,10 +28,22 @@ describe("DefaultCipherSdkService", () => {
|
||||
mockAdminSdk = {
|
||||
create: jest.fn(),
|
||||
edit: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
delete_many: jest.fn().mockResolvedValue(undefined),
|
||||
soft_delete: jest.fn().mockResolvedValue(undefined),
|
||||
soft_delete_many: jest.fn().mockResolvedValue(undefined),
|
||||
restore: jest.fn().mockResolvedValue(undefined),
|
||||
restore_many: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockCiphersSdk = {
|
||||
create: jest.fn(),
|
||||
edit: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
delete_many: jest.fn().mockResolvedValue(undefined),
|
||||
soft_delete: jest.fn().mockResolvedValue(undefined),
|
||||
soft_delete_many: jest.fn().mockResolvedValue(undefined),
|
||||
restore: jest.fn().mockResolvedValue(undefined),
|
||||
restore_many: jest.fn().mockResolvedValue(undefined),
|
||||
admin: jest.fn().mockReturnValue(mockAdminSdk),
|
||||
};
|
||||
mockVaultSdk = {
|
||||
@@ -243,4 +255,280 @@ describe("DefaultCipherSdkService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWithServer()", () => {
|
||||
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
|
||||
|
||||
it("should delete cipher using SDK when asAdmin is false", async () => {
|
||||
await cipherSdkService.deleteWithServer(testCipherId, userId, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.delete).toHaveBeenCalledWith(testCipherId);
|
||||
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should delete cipher using SDK admin API when asAdmin is true", async () => {
|
||||
await cipherSdkService.deleteWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.delete).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
|
||||
await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow(
|
||||
"SDK not available",
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to delete cipher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
mockCiphersSdk.delete.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to delete cipher"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteManyWithServer()", () => {
|
||||
const testCipherIds = [
|
||||
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
|
||||
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
|
||||
];
|
||||
|
||||
it("should delete multiple ciphers using SDK when asAdmin is false", async () => {
|
||||
await cipherSdkService.deleteManyWithServer(testCipherIds, userId, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.delete_many).toHaveBeenCalledWith(testCipherIds);
|
||||
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should delete multiple ciphers using SDK admin API when asAdmin is true", async () => {
|
||||
await cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.delete_many).toHaveBeenCalledWith(testCipherIds, orgId);
|
||||
});
|
||||
|
||||
it("should throw error when asAdmin is true but orgId is missing", async () => {
|
||||
await expect(
|
||||
cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, undefined),
|
||||
).rejects.toThrow("Organization ID is required for admin delete.");
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
|
||||
await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow(
|
||||
"SDK not available",
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to delete multiple ciphers"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
mockCiphersSdk.delete_many.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to delete multiple ciphers"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("softDeleteWithServer()", () => {
|
||||
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
|
||||
|
||||
it("should soft delete cipher using SDK when asAdmin is false", async () => {
|
||||
await cipherSdkService.softDeleteWithServer(testCipherId, userId, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.soft_delete).toHaveBeenCalledWith(testCipherId);
|
||||
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should soft delete cipher using SDK admin API when asAdmin is true", async () => {
|
||||
await cipherSdkService.softDeleteWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.soft_delete).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
|
||||
await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow(
|
||||
"SDK not available",
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to soft delete cipher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
mockCiphersSdk.soft_delete.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to soft delete cipher"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("softDeleteManyWithServer()", () => {
|
||||
const testCipherIds = [
|
||||
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
|
||||
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
|
||||
];
|
||||
|
||||
it("should soft delete multiple ciphers using SDK when asAdmin is false", async () => {
|
||||
await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds);
|
||||
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should soft delete multiple ciphers using SDK admin API when asAdmin is true", async () => {
|
||||
await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds, orgId);
|
||||
});
|
||||
|
||||
it("should throw error when asAdmin is true but orgId is missing", async () => {
|
||||
await expect(
|
||||
cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, undefined),
|
||||
).rejects.toThrow("Organization ID is required for admin soft delete.");
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
cipherSdkService.softDeleteManyWithServer(testCipherIds, userId),
|
||||
).rejects.toThrow("SDK not available");
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to soft delete multiple ciphers"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
mockCiphersSdk.soft_delete_many.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(
|
||||
cipherSdkService.softDeleteManyWithServer(testCipherIds, userId),
|
||||
).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to soft delete multiple ciphers"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreWithServer()", () => {
|
||||
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
|
||||
|
||||
it("should restore cipher using SDK when asAdmin is false", async () => {
|
||||
await cipherSdkService.restoreWithServer(testCipherId, userId, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.restore).toHaveBeenCalledWith(testCipherId);
|
||||
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should restore cipher using SDK admin API when asAdmin is true", async () => {
|
||||
await cipherSdkService.restoreWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.restore).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
|
||||
await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow(
|
||||
"SDK not available",
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to restore cipher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
mockCiphersSdk.restore.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to restore cipher"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreManyWithServer()", () => {
|
||||
const testCipherIds = [
|
||||
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
|
||||
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
|
||||
];
|
||||
|
||||
it("should restore multiple ciphers using SDK when orgId is not provided", async () => {
|
||||
await cipherSdkService.restoreManyWithServer(testCipherIds, userId);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.restore_many).toHaveBeenCalledWith(testCipherIds);
|
||||
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should restore multiple ciphers using SDK admin API when orgId is provided", async () => {
|
||||
const orgIdString = orgId as string;
|
||||
await cipherSdkService.restoreManyWithServer(testCipherIds, userId, orgIdString);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.restore_many).toHaveBeenCalledWith(testCipherIds, orgIdString);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
|
||||
await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow(
|
||||
"SDK not available",
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to restore multiple ciphers"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
mockCiphersSdk.restore_many.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to restore multiple ciphers"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { firstValueFrom, switchMap, catchError } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { SdkService, asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal";
|
||||
|
||||
@@ -79,4 +79,185 @@ export class DefaultCipherSdkService implements CipherSdkService {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
if (asAdmin) {
|
||||
await ref.value.vault().ciphers().admin().delete(asUuid(id));
|
||||
} else {
|
||||
await ref.value.vault().ciphers().delete(asUuid(id));
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to delete cipher: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
if (asAdmin) {
|
||||
if (orgId == null) {
|
||||
throw new Error("Organization ID is required for admin delete.");
|
||||
}
|
||||
await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.admin()
|
||||
.delete_many(
|
||||
ids.map((id) => asUuid(id)),
|
||||
asUuid(orgId),
|
||||
);
|
||||
} else {
|
||||
await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.delete_many(ids.map((id) => asUuid(id)));
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to delete multiple ciphers: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
if (asAdmin) {
|
||||
await ref.value.vault().ciphers().admin().soft_delete(asUuid(id));
|
||||
} else {
|
||||
await ref.value.vault().ciphers().soft_delete(asUuid(id));
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to soft delete cipher: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async softDeleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
if (asAdmin) {
|
||||
if (orgId == null) {
|
||||
throw new Error("Organization ID is required for admin soft delete.");
|
||||
}
|
||||
await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.admin()
|
||||
.soft_delete_many(
|
||||
ids.map((id) => asUuid(id)),
|
||||
asUuid(orgId),
|
||||
);
|
||||
} else {
|
||||
await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.soft_delete_many(ids.map((id) => asUuid(id)));
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to soft delete multiple ciphers: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
if (asAdmin) {
|
||||
await ref.value.vault().ciphers().admin().restore(asUuid(id));
|
||||
} else {
|
||||
await ref.value.vault().ciphers().restore(asUuid(id));
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to restore cipher: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
|
||||
// No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable
|
||||
// The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore
|
||||
if (orgId) {
|
||||
await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.admin()
|
||||
.restore_many(
|
||||
ids.map((id) => asUuid(id)),
|
||||
asUuid(orgId),
|
||||
);
|
||||
} else {
|
||||
await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.restore_many(ids.map((id) => asUuid(id)));
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to restore multiple ciphers: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,8 @@ describe("Cipher Service", () => {
|
||||
|
||||
let cipherService: CipherService;
|
||||
let encryptionContext: EncryptionContext;
|
||||
// BehaviorSubject for SDK feature flag - allows tests to change the value after service instantiation
|
||||
let sdkCrudFeatureFlag$: BehaviorSubject<boolean>;
|
||||
|
||||
beforeEach(() => {
|
||||
encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES));
|
||||
@@ -132,6 +134,10 @@ describe("Cipher Service", () => {
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
|
||||
|
||||
// Create BehaviorSubject for SDK feature flag - tests can update this to change behavior
|
||||
sdkCrudFeatureFlag$ = new BehaviorSubject<boolean>(false);
|
||||
configService.getFeatureFlag$.mockReturnValue(sdkCrudFeatureFlag$.asObservable());
|
||||
|
||||
cipherService = new CipherService(
|
||||
keyService,
|
||||
domainSettingsService,
|
||||
@@ -280,9 +286,7 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should delegate to cipherSdkService when feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(true);
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
const expectedResult = new CipherView(encryptionContext.cipher);
|
||||
@@ -315,9 +319,9 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => {
|
||||
configService.getFeatureFlag
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const testCipher = new Cipher(cipherData);
|
||||
testCipher.organizationId = orgId;
|
||||
@@ -368,9 +372,7 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should delegate to cipherSdkService when feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(true);
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const testCipher = new Cipher(cipherData);
|
||||
const cipherView = new CipherView(testCipher);
|
||||
@@ -392,9 +394,7 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should delegate to cipherSdkService with orgAdmin when feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(true);
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const testCipher = new Cipher(cipherData);
|
||||
const cipherView = new CipherView(testCipher);
|
||||
@@ -1009,6 +1009,238 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWithServer()", () => {
|
||||
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
|
||||
|
||||
it("should call apiService.deleteCipher when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "deleteCipher").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.deleteWithServer(testCipherId, userId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should call apiService.deleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "deleteCipherAdmin").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.deleteWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should use SDK to delete cipher when feature flag is enabled", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "deleteWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.deleteWithServer(testCipherId, userId, false);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it("should use SDK admin delete when feature flag is enabled and asAdmin is true", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "deleteWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.deleteWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteManyWithServer()", () => {
|
||||
const testCipherIds = [
|
||||
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
|
||||
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
|
||||
];
|
||||
|
||||
it("should call apiService.deleteManyCiphers when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "deleteManyCiphers").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.deleteManyWithServer(testCipherIds, userId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call apiService.deleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "deleteManyCiphersAdmin").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use SDK to delete multiple ciphers when feature flag is enabled", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "deleteManyWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.deleteManyWithServer(testCipherIds, userId, false);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it("should use SDK admin delete many when feature flag is enabled and asAdmin is true", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "deleteManyWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("softDeleteWithServer()", () => {
|
||||
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
|
||||
|
||||
it("should call apiService.putDeleteCipher when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "putDeleteCipher").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.softDeleteWithServer(testCipherId, userId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should call apiService.putDeleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "putDeleteCipherAdmin").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.softDeleteWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should use SDK to soft delete cipher when feature flag is enabled", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "softDeleteWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.softDeleteWithServer(testCipherId, userId, false);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it("should use SDK admin soft delete when feature flag is enabled and asAdmin is true", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "softDeleteWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.softDeleteWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("softDeleteManyWithServer()", () => {
|
||||
const testCipherIds = [
|
||||
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
|
||||
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
|
||||
];
|
||||
|
||||
it("should call apiService.putDeleteManyCiphers when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "putDeleteManyCiphers").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.softDeleteManyWithServer(testCipherIds, userId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call apiService.putDeleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest
|
||||
.spyOn(apiService, "putDeleteManyCiphersAdmin")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use SDK to soft delete multiple ciphers when feature flag is enabled", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "softDeleteManyWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.softDeleteManyWithServer(testCipherIds, userId, false);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it("should use SDK admin soft delete many when feature flag is enabled and asAdmin is true", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "softDeleteManyWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace (no upsert)", () => {
|
||||
// In order to set up initial state we need to manually update the encrypted state
|
||||
// which will result in an emission. All tests will have this baseline emission.
|
||||
|
||||
@@ -106,6 +106,13 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
*/
|
||||
private clearCipherViewsForUser$: Subject<UserId> = new Subject<UserId>();
|
||||
|
||||
/**
|
||||
* Observable exposing the feature flag status for using the SDK for cipher CRUD operations.
|
||||
*/
|
||||
private readonly sdkCipherCrudEnabled$: Observable<boolean> = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.PM27632_SdkCipherCrudOperations,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
private domainSettingsService: DomainSettingsService,
|
||||
@@ -909,9 +916,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
userId: UserId,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView> {
|
||||
const useSdk = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM27632_SdkCipherCrudOperations,
|
||||
);
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
|
||||
if (useSdk) {
|
||||
return (
|
||||
@@ -970,9 +975,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView> {
|
||||
const useSdk = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM27632_SdkCipherCrudOperations,
|
||||
);
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
|
||||
if (useSdk) {
|
||||
return await this.updateWithServerUsingSdk(cipherView, userId, originalCipherView, orgAdmin);
|
||||
@@ -1389,7 +1392,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.encryptedCiphersState(userId).update(() => ciphers);
|
||||
}
|
||||
|
||||
async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
|
||||
async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
await this.cipherSdkService.deleteWithServer(id, userId, asAdmin);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (asAdmin) {
|
||||
await this.apiService.deleteCipherAdmin(id);
|
||||
} else {
|
||||
@@ -1399,7 +1409,19 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.delete(id, userId);
|
||||
}
|
||||
|
||||
async deleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise<any> {
|
||||
async deleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
await this.cipherSdkService.deleteManyWithServer(ids, userId, asAdmin, orgId);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new CipherBulkDeleteRequest(ids);
|
||||
if (asAdmin) {
|
||||
await this.apiService.deleteManyCiphersAdmin(request);
|
||||
@@ -1539,7 +1561,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
};
|
||||
}
|
||||
|
||||
async softDelete(id: string | string[], userId: UserId): Promise<any> {
|
||||
async softDelete(id: string | string[], userId: UserId): Promise<void> {
|
||||
let ciphers = await firstValueFrom(this.ciphers$(userId));
|
||||
if (ciphers == null) {
|
||||
return;
|
||||
@@ -1567,7 +1589,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
});
|
||||
}
|
||||
|
||||
async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
|
||||
async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
await this.cipherSdkService.softDeleteWithServer(id, userId, asAdmin);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (asAdmin) {
|
||||
await this.apiService.putDeleteCipherAdmin(id);
|
||||
} else {
|
||||
@@ -1577,7 +1606,19 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.softDelete(id, userId);
|
||||
}
|
||||
|
||||
async softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise<any> {
|
||||
async softDeleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
await this.cipherSdkService.softDeleteManyWithServer(ids, userId, asAdmin, orgId);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new CipherBulkDeleteRequest(ids);
|
||||
if (asAdmin) {
|
||||
await this.apiService.putDeleteManyCiphersAdmin(request);
|
||||
@@ -1621,7 +1662,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
});
|
||||
}
|
||||
|
||||
async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
|
||||
async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
await this.cipherSdkService.restoreWithServer(id, userId, asAdmin);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
let response;
|
||||
if (asAdmin) {
|
||||
response = await this.apiService.putRestoreCipherAdmin(id);
|
||||
@@ -1637,6 +1685,13 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
* The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore
|
||||
*/
|
||||
async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
await this.cipherSdkService.restoreManyWithServer(ids, userId, orgId);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
let response;
|
||||
|
||||
if (orgId) {
|
||||
|
||||
@@ -25,12 +25,3 @@ export const SHOW_IDENTITIES_CURRENT_TAB = new UserKeyDefinition<boolean>(
|
||||
clearOn: [], // do not clear user settings
|
||||
},
|
||||
);
|
||||
|
||||
export const CLICK_ITEMS_AUTOFILL_VAULT_VIEW = new UserKeyDefinition<boolean>(
|
||||
VAULT_SETTINGS_DISK,
|
||||
"clickItemsToAutofillOnVaultView",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
clearOn: [], // do not clear user settings
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Observable, combineLatest, map, shareReplay } from "rxjs";
|
||||
import { Observable, combineLatest, map } from "rxjs";
|
||||
|
||||
import { ActiveUserState, GlobalState, StateProvider } from "../../../platform/state";
|
||||
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "../../abstractions/vault-settings/vault-settings.service";
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
SHOW_CARDS_CURRENT_TAB,
|
||||
SHOW_IDENTITIES_CURRENT_TAB,
|
||||
USER_ENABLE_PASSKEYS,
|
||||
CLICK_ITEMS_AUTOFILL_VAULT_VIEW,
|
||||
} from "../key-state/vault-settings.state";
|
||||
import { RestrictedItemTypesService } from "../restricted-item-types.service";
|
||||
|
||||
@@ -49,17 +48,6 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
|
||||
readonly showIdentitiesCurrentTab$: Observable<boolean> =
|
||||
this.showIdentitiesCurrentTabState.state$.pipe(map((x) => x ?? true));
|
||||
|
||||
private clickItemsToAutofillVaultViewState: ActiveUserState<boolean> =
|
||||
this.stateProvider.getActive(CLICK_ITEMS_AUTOFILL_VAULT_VIEW);
|
||||
/**
|
||||
* {@link VaultSettingsServiceAbstraction.clickItemsToAutofillVaultView$$}
|
||||
*/
|
||||
readonly clickItemsToAutofillVaultView$: Observable<boolean> =
|
||||
this.clickItemsToAutofillVaultViewState.state$.pipe(
|
||||
map((x) => x ?? false),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private restrictedItemTypesService: RestrictedItemTypesService,
|
||||
@@ -79,13 +67,6 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
|
||||
await this.showIdentitiesCurrentTabState.update(() => value);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link VaultSettingsServiceAbstraction.setClickItemsToAutofillVaultView}
|
||||
*/
|
||||
async setClickItemsToAutofillVaultView(value: boolean): Promise<void> {
|
||||
await this.clickItemsToAutofillVaultViewState.update(() => value);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link VaultSettingsServiceAbstraction.setEnablePasskeys}
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FieldType } from "@bitwarden/common/vault/enums";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import { KeePass2XmlImporter } from "./keepass2-xml-importer";
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
TestData,
|
||||
TestData1,
|
||||
TestData2,
|
||||
TestDataWithProtectedFields,
|
||||
} from "./spec-data/keepass2-xml/keepass2-xml-importer-testdata";
|
||||
|
||||
describe("KeePass2 Xml Importer", () => {
|
||||
@@ -43,4 +45,73 @@ describe("KeePass2 Xml Importer", () => {
|
||||
const result = await importer.parse(TestData2);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
describe("protected fields handling", () => {
|
||||
it("should import protected custom fields as hidden fields", async () => {
|
||||
const importer = new KeePass2XmlImporter();
|
||||
const result = await importer.parse(TestDataWithProtectedFields);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.ciphers.length).toBe(1);
|
||||
|
||||
const cipher = result.ciphers[0];
|
||||
expect(cipher.name).toBe("Test Entry");
|
||||
expect(cipher.login.username).toBe("testuser");
|
||||
expect(cipher.login.password).toBe("testpass");
|
||||
expect(cipher.notes).toContain("Regular notes");
|
||||
|
||||
// Check that protected custom field is imported as hidden field
|
||||
const protectedField = cipher.fields.find((f) => f.name === "SAFE UN-LOCKING instructions");
|
||||
expect(protectedField).toBeDefined();
|
||||
expect(protectedField?.value).toBe("Secret instructions here");
|
||||
expect(protectedField?.type).toBe(FieldType.Hidden);
|
||||
|
||||
// Check that regular custom field is imported as text field
|
||||
const regularField = cipher.fields.find((f) => f.name === "CustomField");
|
||||
expect(regularField).toBeDefined();
|
||||
expect(regularField?.value).toBe("Custom value");
|
||||
expect(regularField?.type).toBe(FieldType.Text);
|
||||
});
|
||||
|
||||
it("should import long protected fields as hidden fields (not appended to notes)", async () => {
|
||||
const importer = new KeePass2XmlImporter();
|
||||
const result = await importer.parse(TestDataWithProtectedFields);
|
||||
|
||||
const cipher = result.ciphers[0];
|
||||
|
||||
// Long protected field should be imported as hidden field
|
||||
const longField = cipher.fields.find((f) => f.name === "LongProtectedField");
|
||||
expect(longField).toBeDefined();
|
||||
expect(longField?.type).toBe(FieldType.Hidden);
|
||||
expect(longField?.value).toContain("This is a very long protected field");
|
||||
|
||||
// Should not be appended to notes
|
||||
expect(cipher.notes).not.toContain("LongProtectedField");
|
||||
});
|
||||
|
||||
it("should import multiline protected fields as hidden fields (not appended to notes)", async () => {
|
||||
const importer = new KeePass2XmlImporter();
|
||||
const result = await importer.parse(TestDataWithProtectedFields);
|
||||
|
||||
const cipher = result.ciphers[0];
|
||||
|
||||
// Multiline protected field should be imported as hidden field
|
||||
const multilineField = cipher.fields.find((f) => f.name === "MultilineProtectedField");
|
||||
expect(multilineField).toBeDefined();
|
||||
expect(multilineField?.type).toBe(FieldType.Hidden);
|
||||
expect(multilineField?.value).toContain("Line 1");
|
||||
|
||||
// Should not be appended to notes
|
||||
expect(cipher.notes).not.toContain("MultilineProtectedField");
|
||||
});
|
||||
|
||||
it("should not append protected custom fields to notes", async () => {
|
||||
const importer = new KeePass2XmlImporter();
|
||||
const result = await importer.parse(TestDataWithProtectedFields);
|
||||
|
||||
const cipher = result.ciphers[0];
|
||||
expect(cipher.notes).not.toContain("SAFE UN-LOCKING instructions");
|
||||
expect(cipher.notes).not.toContain("Secret instructions here");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { FieldType } from "@bitwarden/common/vault/enums";
|
||||
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import { ImportResult } from "../models/import-result";
|
||||
@@ -92,16 +93,26 @@ export class KeePass2XmlImporter extends BaseImporter implements Importer {
|
||||
} else if (key === "Notes") {
|
||||
cipher.notes += value + "\n";
|
||||
} else {
|
||||
let type = FieldType.Text;
|
||||
const attrs = valueEl.attributes as any;
|
||||
if (
|
||||
const isProtected =
|
||||
attrs.length > 0 &&
|
||||
attrs.ProtectInMemory != null &&
|
||||
attrs.ProtectInMemory.value === "True"
|
||||
) {
|
||||
type = FieldType.Hidden;
|
||||
attrs.ProtectInMemory.value === "True";
|
||||
|
||||
if (isProtected) {
|
||||
// Protected fields should always be imported as hidden fields,
|
||||
// regardless of length or newlines (fixes #16897)
|
||||
if (cipher.fields == null) {
|
||||
cipher.fields = [];
|
||||
}
|
||||
const field = new FieldView();
|
||||
field.type = FieldType.Hidden;
|
||||
field.name = key;
|
||||
field.value = value;
|
||||
cipher.fields.push(field);
|
||||
} else {
|
||||
this.processKvp(cipher, key, value, FieldType.Text);
|
||||
}
|
||||
this.processKvp(cipher, key, value, type);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ export class RoboFormCsvImporter extends BaseImporter implements Importer {
|
||||
cipher.notes = this.getValueOrDefault(value.Note);
|
||||
cipher.name = this.getValueOrDefault(value.Name, "--");
|
||||
cipher.login.username = this.getValueOrDefault(value.Login);
|
||||
cipher.login.password = this.getValueOrDefault(value.Pwd);
|
||||
cipher.login.uris = this.makeUriArray(value.Url);
|
||||
cipher.login.password =
|
||||
this.getValueOrDefault(value.Pwd) ?? this.getValueOrDefault(value.Password);
|
||||
cipher.login.uris = this.makeUriArray(value.Url) ?? this.makeUriArray(value.URL);
|
||||
|
||||
if (!this.isNullOrWhitespace(value.Rf_fields)) {
|
||||
this.parseRfFields(cipher, value);
|
||||
|
||||
@@ -354,6 +354,57 @@ line2</Value>
|
||||
</Group>
|
||||
<DeletedObjects />
|
||||
</KeePassFile>`;
|
||||
export const TestDataWithProtectedFields = `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<KeePassFile>
|
||||
<Root>
|
||||
<Group>
|
||||
<UUID>KvS57lVwl13AfGFLwkvq4Q==</UUID>
|
||||
<Name>Root</Name>
|
||||
<Entry>
|
||||
<UUID>fAa543oYlgnJKkhKag5HLw==</UUID>
|
||||
<String>
|
||||
<Key>Title</Key>
|
||||
<Value>Test Entry</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>UserName</Key>
|
||||
<Value>testuser</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>Password</Key>
|
||||
<Value ProtectInMemory="True">testpass</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>URL</Key>
|
||||
<Value>https://example.com</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>Notes</Key>
|
||||
<Value>Regular notes</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>SAFE UN-LOCKING instructions</Key>
|
||||
<Value ProtectInMemory="True">Secret instructions here</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>CustomField</Key>
|
||||
<Value>Custom value</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>LongProtectedField</Key>
|
||||
<Value ProtectInMemory="True">This is a very long protected field value that exceeds 200 characters. It contains sensitive information that should be imported as a hidden field and not appended to the notes section. This text is long enough to trigger the old behavior.</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>MultilineProtectedField</Key>
|
||||
<Value ProtectInMemory="True">Line 1
|
||||
Line 2
|
||||
Line 3</Value>
|
||||
</String>
|
||||
</Entry>
|
||||
</Group>
|
||||
</Root>
|
||||
</KeePassFile>`;
|
||||
|
||||
export const TestData2 = `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<Meta>
|
||||
<Generator>KeePass</Generator>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { PrfKey, UserKey } from "@bitwarden/common/types/key";
|
||||
@@ -267,7 +268,7 @@ export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService
|
||||
private async getRpIdForUser(userId: UserId): Promise<string | undefined> {
|
||||
try {
|
||||
const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId));
|
||||
const hostname = environment.getHostname();
|
||||
const hostname = Utils.getHost(environment.getWebVaultUrl());
|
||||
|
||||
// The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host.
|
||||
if (!hostname) {
|
||||
|
||||
@@ -179,18 +179,36 @@ describe("BiometricStateService", () => {
|
||||
});
|
||||
|
||||
describe("biometricUnlockEnabled$", () => {
|
||||
it("emits when biometricUnlockEnabled state is updated", async () => {
|
||||
const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED);
|
||||
state.nextState(true);
|
||||
describe("no user id provided, active user", () => {
|
||||
it("emits when biometricUnlockEnabled state is updated", async () => {
|
||||
const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED);
|
||||
state.nextState(true);
|
||||
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true);
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(true);
|
||||
});
|
||||
|
||||
it("emits false when biometricUnlockEnabled state is undefined", async () => {
|
||||
const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED);
|
||||
state.nextState(undefined as unknown as boolean);
|
||||
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("emits false when biometricUnlockEnabled state is undefined", async () => {
|
||||
const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED);
|
||||
state.nextState(undefined as unknown as boolean);
|
||||
describe("user id provided", () => {
|
||||
it("returns biometricUnlockEnabled state for the given user", async () => {
|
||||
stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true);
|
||||
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(false);
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$(userId))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the state is not set", async () => {
|
||||
stateProvider.singleUser
|
||||
.getFake(userId, BIOMETRIC_UNLOCK_ENABLED)
|
||||
.nextState(undefined as unknown as boolean);
|
||||
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$(userId))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,7 +216,7 @@ describe("BiometricStateService", () => {
|
||||
it("updates biometricUnlockEnabled$", async () => {
|
||||
await sut.setBiometricUnlockEnabled(true);
|
||||
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true);
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(true);
|
||||
});
|
||||
|
||||
it("updates state", async () => {
|
||||
@@ -210,22 +228,6 @@ describe("BiometricStateService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBiometricUnlockEnabled", () => {
|
||||
it("returns biometricUnlockEnabled state for the given user", async () => {
|
||||
stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true);
|
||||
|
||||
expect(await sut.getBiometricUnlockEnabled(userId)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the state is not set", async () => {
|
||||
stateProvider.singleUser
|
||||
.getFake(userId, BIOMETRIC_UNLOCK_ENABLED)
|
||||
.nextState(undefined as unknown as boolean);
|
||||
|
||||
expect(await sut.getBiometricUnlockEnabled(userId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFingerprintValidated", () => {
|
||||
it("updates fingerprintValidated$", async () => {
|
||||
await sut.setFingerprintValidated(true);
|
||||
|
||||
@@ -18,9 +18,11 @@ import {
|
||||
|
||||
export abstract class BiometricStateService {
|
||||
/**
|
||||
* `true` if the currently active user has elected to store a biometric key to unlock their vault.
|
||||
* Returns whether biometric unlock is enabled for a user.
|
||||
* @param userId The user id to check. If not provided, returns the state for the currently active user.
|
||||
* @returns An observable that emits `true` if the user has elected to store a biometric key to unlock their vault.
|
||||
*/
|
||||
abstract biometricUnlockEnabled$: Observable<boolean>; // used to be biometricUnlock
|
||||
abstract biometricUnlockEnabled$(userId?: UserId): Observable<boolean>;
|
||||
/**
|
||||
* If the user has elected to require a password on first unlock of an application instance, this key will store the
|
||||
* encrypted client key half used to unlock the vault.
|
||||
@@ -53,6 +55,7 @@ export abstract class BiometricStateService {
|
||||
|
||||
/**
|
||||
* Gets the biometric unlock enabled state for the given user.
|
||||
* @deprecated Use {@link biometricUnlockEnabled$} instead
|
||||
* @param userId user Id to check
|
||||
*/
|
||||
abstract getBiometricUnlockEnabled(userId: UserId): Promise<boolean>;
|
||||
@@ -103,7 +106,6 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
private promptAutomaticallyState: ActiveUserState<boolean>;
|
||||
private fingerprintValidatedState: GlobalState<boolean>;
|
||||
private lastProcessReloadState: GlobalState<Date>;
|
||||
biometricUnlockEnabled$: Observable<boolean>;
|
||||
encryptedClientKeyHalf$: Observable<EncString | null>;
|
||||
promptCancelled$: Observable<boolean>;
|
||||
promptAutomatically$: Observable<boolean>;
|
||||
@@ -112,7 +114,6 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
|
||||
constructor(private stateProvider: StateProvider) {
|
||||
this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED);
|
||||
this.biometricUnlockEnabled$ = this.biometricUnlockEnabledState.state$.pipe(map(Boolean));
|
||||
|
||||
this.encryptedClientKeyHalfState = this.stateProvider.getActive(ENCRYPTED_CLIENT_KEY_HALF);
|
||||
this.encryptedClientKeyHalf$ = this.encryptedClientKeyHalfState.state$.pipe(
|
||||
@@ -142,6 +143,15 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
await this.biometricUnlockEnabledState.update(() => enabled);
|
||||
}
|
||||
|
||||
biometricUnlockEnabled$(userId?: UserId): Observable<boolean> {
|
||||
if (userId != null) {
|
||||
return this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean));
|
||||
}
|
||||
// Backwards compatibility for active user state
|
||||
// TODO remove with https://bitwarden.atlassian.net/browse/PM-12043
|
||||
return this.biometricUnlockEnabledState.state$.pipe(map(Boolean));
|
||||
}
|
||||
|
||||
async getBiometricUnlockEnabled(userId: UserId): Promise<boolean> {
|
||||
return await firstValueFrom(
|
||||
this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)),
|
||||
|
||||
126
package-lock.json
generated
126
package-lock.json
generated
@@ -14,15 +14,15 @@
|
||||
"libs/**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@angular/animations": "20.3.15",
|
||||
"@angular/animations": "20.3.16",
|
||||
"@angular/cdk": "20.2.14",
|
||||
"@angular/common": "20.3.15",
|
||||
"@angular/compiler": "20.3.15",
|
||||
"@angular/core": "20.3.15",
|
||||
"@angular/forms": "20.3.15",
|
||||
"@angular/platform-browser": "20.3.15",
|
||||
"@angular/platform-browser-dynamic": "20.3.15",
|
||||
"@angular/router": "20.3.15",
|
||||
"@angular/common": "20.3.16",
|
||||
"@angular/compiler": "20.3.16",
|
||||
"@angular/core": "20.3.16",
|
||||
"@angular/forms": "20.3.16",
|
||||
"@angular/platform-browser": "20.3.16",
|
||||
"@angular/platform-browser-dynamic": "20.3.16",
|
||||
"@angular/router": "20.3.16",
|
||||
"@bitwarden/commercial-sdk-internal": "0.2.0-main.470",
|
||||
"@bitwarden/sdk-internal": "0.2.0-main.470",
|
||||
"@electron/fuses": "1.8.0",
|
||||
@@ -74,7 +74,7 @@
|
||||
"@angular-devkit/build-angular": "20.3.12",
|
||||
"@angular-eslint/schematics": "20.7.0",
|
||||
"@angular/cli": "20.3.12",
|
||||
"@angular/compiler-cli": "20.3.15",
|
||||
"@angular/compiler-cli": "20.3.16",
|
||||
"@babel/core": "7.28.5",
|
||||
"@babel/preset-env": "7.28.5",
|
||||
"@compodoc/compodoc": "1.1.32",
|
||||
@@ -109,7 +109,7 @@
|
||||
"@types/koa-json": "2.0.23",
|
||||
"@types/lowdb": "1.0.15",
|
||||
"@types/lunr": "2.3.7",
|
||||
"@types/node": "22.19.3",
|
||||
"@types/node": "22.19.7",
|
||||
"@types/node-fetch": "2.6.13",
|
||||
"@types/node-forge": "1.3.14",
|
||||
"@types/papaparse": "5.5.0",
|
||||
@@ -2203,9 +2203,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/animations": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.15.tgz",
|
||||
"integrity": "sha512-ikyKfhkxoqQA6JcBN0B9RaN6369sM1XYX81Id0lI58dmWCe7gYfrTp8ejqxxKftl514psQO3pkW8Gn1nJ131Gw==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.16.tgz",
|
||||
"integrity": "sha512-N83/GFY5lKNyWgPV3xHHy2rb3/eP1ZLzSVI+dmMVbf3jbqwY1YPQcMiAG8UDzaILY1Dkus91kWLF8Qdr3nHAzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2214,7 +2214,7 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "20.3.15"
|
||||
"@angular/core": "20.3.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/build": {
|
||||
@@ -2627,9 +2627,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/common": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.15.tgz",
|
||||
"integrity": "sha512-k4mCXWRFiOHK3bUKfWkRQQ8KBPxW8TAJuKLYCsSHPCpMz6u0eA1F0VlrnOkZVKWPI792fOaEAWH2Y4PTaXlUHw==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.16.tgz",
|
||||
"integrity": "sha512-GRAziNlntwdnJy3F+8zCOvDdy7id0gITjDnM6P9+n2lXvtDuBLGJKU3DWBbvxcCjtD6JK/g/rEX5fbCxbUHkQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2638,14 +2638,14 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "20.3.15",
|
||||
"@angular/core": "20.3.16",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.15.tgz",
|
||||
"integrity": "sha512-lMicIAFAKZXa+BCZWs3soTjNQPZZXrF/WMVDinm8dQcggNarnDj4UmXgKSyXkkyqK5SLfnLsXVzrX6ndVT6z7A==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.16.tgz",
|
||||
"integrity": "sha512-Pt9Ms9GwTThgzdxWBwMfN8cH1JEtQ2DK5dc2yxYtPSaD+WKmG9AVL1PrzIYQEbaKcWk2jxASUHpEWSlNiwo8uw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2655,9 +2655,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler-cli": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.15.tgz",
|
||||
"integrity": "sha512-8sJoxodxsfyZ8eJ5r6Bx7BCbazXYgsZ1+dE8t5u5rTQ6jNggwNtYEzkyReoD5xvP+MMtRkos3xpwq4rtFnpI6A==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.16.tgz",
|
||||
"integrity": "sha512-l3xF/fXfJAl/UrNnH9Ufkr79myjMgXdHq1mmmph2UnpeqilRB1b8lC9sLBV9MipQHVn3dwocxMIvtrcryfOaXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2678,7 +2678,7 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "20.3.15",
|
||||
"@angular/compiler": "20.3.16",
|
||||
"typescript": ">=5.8 <6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -2864,9 +2864,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/core": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.15.tgz",
|
||||
"integrity": "sha512-NMbX71SlTZIY9+rh/SPhRYFJU0pMJYW7z/TBD4lqiO+b0DTOIg1k7Pg9ydJGqSjFO1Z4dQaA6TteNuF99TJCNw==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.16.tgz",
|
||||
"integrity": "sha512-KSFPKvOmWWLCJBbEO+CuRUXfecX2FRuO0jNi9c54ptXMOPHlK1lIojUnyXmMNzjdHgRug8ci9qDuftvC2B7MKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2875,7 +2875,7 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "20.3.15",
|
||||
"@angular/compiler": "20.3.16",
|
||||
"rxjs": "^6.5.3 || ^7.4.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
@@ -2889,9 +2889,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/forms": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.15.tgz",
|
||||
"integrity": "sha512-gS5hQkinq52pm/7mxz4yHPCzEcmRWjtUkOVddPH0V1BW/HMni/p4Y6k2KqKBeGb9p8S5EAp6PDxDVLOPukp3mg==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.16.tgz",
|
||||
"integrity": "sha512-1yzbXpExTqATpVcqA3wGrq4ACFIP3mRxA4pbso5KoJU+/4JfzNFwLsDaFXKpm5uxwchVnj8KM2vPaDOkvtp7NA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2900,16 +2900,16 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "20.3.15",
|
||||
"@angular/core": "20.3.15",
|
||||
"@angular/platform-browser": "20.3.15",
|
||||
"@angular/common": "20.3.16",
|
||||
"@angular/core": "20.3.16",
|
||||
"@angular/platform-browser": "20.3.16",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.15.tgz",
|
||||
"integrity": "sha512-TxRM/wTW/oGXv/3/Iohn58yWoiYXOaeEnxSasiGNS1qhbkcKtR70xzxW6NjChBUYAixz2ERkLURkpx3pI8Q6Dw==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.16.tgz",
|
||||
"integrity": "sha512-YsrLS6vyS77i4pVHg4gdSBW74qvzHjpQRTVQ5Lv/OxIjJdYYYkMmjNalCNgy1ZuyY6CaLIB11ccxhrNnxfKGOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2918,9 +2918,9 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": "20.3.15",
|
||||
"@angular/common": "20.3.15",
|
||||
"@angular/core": "20.3.15"
|
||||
"@angular/animations": "20.3.16",
|
||||
"@angular/common": "20.3.16",
|
||||
"@angular/core": "20.3.16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/animations": {
|
||||
@@ -2929,9 +2929,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser-dynamic": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.15.tgz",
|
||||
"integrity": "sha512-RizuRdBt0d6ongQ2y8cr8YsXFyjF8f91vFfpSNw+cFj+oiEmRC1txcWUlH5bPLD9qSDied8qazUi0Tb8VPQDGw==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.16.tgz",
|
||||
"integrity": "sha512-5mECCV9YeKH6ue239GXRTGeDSd/eTbM1j8dDejhm5cGnPBhTxRw4o+GgSrWTYtb6VmIYdwUGBTC+wCBphiaQ2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2940,16 +2940,16 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "20.3.15",
|
||||
"@angular/compiler": "20.3.15",
|
||||
"@angular/core": "20.3.15",
|
||||
"@angular/platform-browser": "20.3.15"
|
||||
"@angular/common": "20.3.16",
|
||||
"@angular/compiler": "20.3.16",
|
||||
"@angular/core": "20.3.16",
|
||||
"@angular/platform-browser": "20.3.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.15.tgz",
|
||||
"integrity": "sha512-6+qgk8swGSoAu7ISSY//GatAyCP36hEvvUgvjbZgkXLLH9yUQxdo77ij05aJ5s0OyB25q/JkqS8VTY0z1yE9NQ==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz",
|
||||
"integrity": "sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2958,9 +2958,9 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "20.3.15",
|
||||
"@angular/core": "20.3.15",
|
||||
"@angular/platform-browser": "20.3.15",
|
||||
"@angular/common": "20.3.16",
|
||||
"@angular/core": "20.3.16",
|
||||
"@angular/platform-browser": "20.3.16",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
@@ -15833,9 +15833,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||
"version": "22.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -32414,9 +32414,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.5",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
|
||||
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
|
||||
"version": "1.11.8",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.8.tgz",
|
||||
"integrity": "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -34690,9 +34690,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ordered-binary": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.0.tgz",
|
||||
"integrity": "sha512-IQh2aMfMIDbPjI/8a3Edr+PiOpcsB7yo8NdW7aHWVaoR/pcDldunMvnnwbk/auPGqmKeAdxtZl7MHX/QmPwhvQ==",
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz",
|
||||
"integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
|
||||
20
package.json
20
package.json
@@ -41,7 +41,7 @@
|
||||
"@angular-devkit/build-angular": "20.3.12",
|
||||
"@angular-eslint/schematics": "20.7.0",
|
||||
"@angular/cli": "20.3.12",
|
||||
"@angular/compiler-cli": "20.3.15",
|
||||
"@angular/compiler-cli": "20.3.16",
|
||||
"@babel/core": "7.28.5",
|
||||
"@babel/preset-env": "7.28.5",
|
||||
"@compodoc/compodoc": "1.1.32",
|
||||
@@ -76,7 +76,7 @@
|
||||
"@types/koa-json": "2.0.23",
|
||||
"@types/lowdb": "1.0.15",
|
||||
"@types/lunr": "2.3.7",
|
||||
"@types/node": "22.19.3",
|
||||
"@types/node": "22.19.7",
|
||||
"@types/node-fetch": "2.6.13",
|
||||
"@types/node-forge": "1.3.14",
|
||||
"@types/papaparse": "5.5.0",
|
||||
@@ -153,15 +153,15 @@
|
||||
"webpack-node-externals": "3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "20.3.15",
|
||||
"@angular/animations": "20.3.16",
|
||||
"@angular/cdk": "20.2.14",
|
||||
"@angular/common": "20.3.15",
|
||||
"@angular/compiler": "20.3.15",
|
||||
"@angular/core": "20.3.15",
|
||||
"@angular/forms": "20.3.15",
|
||||
"@angular/platform-browser": "20.3.15",
|
||||
"@angular/platform-browser-dynamic": "20.3.15",
|
||||
"@angular/router": "20.3.15",
|
||||
"@angular/common": "20.3.16",
|
||||
"@angular/compiler": "20.3.16",
|
||||
"@angular/core": "20.3.16",
|
||||
"@angular/forms": "20.3.16",
|
||||
"@angular/platform-browser": "20.3.16",
|
||||
"@angular/platform-browser-dynamic": "20.3.16",
|
||||
"@angular/router": "20.3.16",
|
||||
"@bitwarden/sdk-internal": "0.2.0-main.470",
|
||||
"@bitwarden/commercial-sdk-internal": "0.2.0-main.470",
|
||||
"@electron/fuses": "1.8.0",
|
||||
|
||||
Reference in New Issue
Block a user