1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-19 10:54:00 +00:00

Merge branch 'main' into dev/kreynolds/tunnel_proto_v2

This commit is contained in:
Katherine Reynolds
2025-11-20 15:09:30 -08:00
37 changed files with 764 additions and 173 deletions

View File

@@ -3286,6 +3286,9 @@ describe("OverlayBackground", () => {
pageDetails: [pageDetailsForTab],
fillNewPassword: true,
allowTotpAutofill: true,
focusedFieldForm: undefined,
focusedFieldOpid: undefined,
inlineMenuFillType: undefined,
});
expect(overlayBackground["inlineMenuCiphers"].entries()).toStrictEqual(
new Map([
@@ -3680,6 +3683,9 @@ describe("OverlayBackground", () => {
pageDetails: [overlayBackground["pageDetailsForTab"][sender.tab.id].get(sender.frameId)],
fillNewPassword: true,
allowTotpAutofill: false,
focusedFieldForm: undefined,
focusedFieldOpid: undefined,
inlineMenuFillType: InlineMenuFillTypes.PasswordGeneration,
});
});
});

View File

@@ -1177,6 +1177,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
allowTotpAutofill: true,
focusedFieldForm: this.focusedFieldData?.focusedFieldForm,
focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid,
inlineMenuFillType: this.focusedFieldData?.inlineMenuFillType,
});
if (totpCode) {
@@ -1863,6 +1864,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
allowTotpAutofill: false,
focusedFieldForm: this.focusedFieldData?.focusedFieldForm,
focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid,
inlineMenuFillType: InlineMenuFillTypes.PasswordGeneration,
});
globalThis.setTimeout(async () => {

View File

@@ -6,6 +6,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { AutofillMessageCommand } from "../../enums/autofill-message.enums";
import { InlineMenuFillType } from "../../enums/autofill-overlay.enum";
import AutofillField from "../../models/autofill-field";
import AutofillForm from "../../models/autofill-form";
import AutofillPageDetails from "../../models/autofill-page-details";
@@ -30,6 +31,7 @@ export interface AutoFillOptions {
autoSubmitLogin?: boolean;
focusedFieldForm?: string;
focusedFieldOpid?: string;
inlineMenuFillType?: InlineMenuFillType;
}
export interface FormData {
@@ -49,6 +51,7 @@ export interface GenerateFillScriptOptions {
tabUrl: string;
defaultUriMatch: UriMatchStrategySetting;
focusedFieldOpid?: string;
inlineMenuFillType?: InlineMenuFillType;
}
export type CollectPageDetailsResponseMessage = {

View File

@@ -1118,6 +1118,12 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* @param autofillFieldData - Autofill field data captured from the form field element.
*/
private async setQualifiedLoginFillType(autofillFieldData: AutofillField) {
// Check if this is a current password field in a password change form
if (this.inlineMenuFieldQualificationService.isUpdateCurrentPasswordField(autofillFieldData)) {
autofillFieldData.inlineMenuFillType = InlineMenuFillTypes.CurrentPasswordUpdate;
return;
}
autofillFieldData.inlineMenuFillType = CipherType.Login;
autofillFieldData.showPasskeys = autofillFieldData.autoCompleteType.includes("webauthn");

View File

@@ -44,6 +44,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums";
import { InlineMenuFillTypes } from "../enums/autofill-overlay.enum";
import { AutofillPort } from "../enums/autofill-port.enum";
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
@@ -103,6 +104,15 @@ describe("AutofillService", () => {
beforeEach(() => {
configService = mock<ConfigService>();
configService.getFeatureFlag$.mockImplementation(() => of(false));
// Initialize domainSettingsService BEFORE it's used
domainSettingsService = new DefaultDomainSettingsService(
fakeStateProvider,
policyService,
accountService,
);
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
scriptInjectorService = new BrowserScriptInjectorService(
domainSettingsService,
platformUtilsService,
@@ -141,12 +151,6 @@ describe("AutofillService", () => {
userNotificationsSettings,
messageListener,
);
domainSettingsService = new DefaultDomainSettingsService(
fakeStateProvider,
policyService,
accountService,
);
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
jest.spyOn(BrowserApi, "tabSendMessage");
});
@@ -2077,6 +2081,193 @@ describe("AutofillService", () => {
});
});
describe("given password generation with inlineMenuFillType", () => {
beforeEach(() => {
pageDetails.forms = undefined;
pageDetails.fields = []; // Clear fields to start fresh
options.inlineMenuFillType = InlineMenuFillTypes.PasswordGeneration;
options.cipher.login.totp = null; // Disable TOTP for these tests
});
it("includes all password fields from the same form when filling with password generation", async () => {
const newPasswordField = createAutofillFieldMock({
opid: "new-password",
type: "password",
form: "validFormId",
elementNumber: 2,
});
const confirmPasswordField = createAutofillFieldMock({
opid: "confirm-password",
type: "password",
form: "validFormId",
elementNumber: 3,
});
pageDetails.fields.push(newPasswordField, confirmPasswordField);
options.focusedFieldOpid = newPasswordField.opid;
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(filledFields[newPasswordField.opid]).toBeDefined();
expect(filledFields[confirmPasswordField.opid]).toBeDefined();
});
it("finds username field for the first password field when generating passwords", async () => {
const newPasswordField = createAutofillFieldMock({
opid: "new-password",
type: "password",
form: "validFormId",
elementNumber: 2,
});
pageDetails.fields.push(newPasswordField);
options.focusedFieldOpid = newPasswordField.opid;
jest.spyOn(autofillService as any, "findUsernameField");
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["findUsernameField"]).toHaveBeenCalledWith(
pageDetails,
expect.objectContaining({ opid: newPasswordField.opid }),
false,
false,
true,
);
});
it("does not include password fields from different forms", async () => {
const formAPasswordField = createAutofillFieldMock({
opid: "form-a-password",
type: "password",
form: "formA",
elementNumber: 1,
});
const formBPasswordField = createAutofillFieldMock({
opid: "form-b-password",
type: "password",
form: "formB",
elementNumber: 2,
});
pageDetails.fields = [formAPasswordField, formBPasswordField];
options.focusedFieldOpid = formAPasswordField.opid;
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(filledFields[formAPasswordField.opid]).toBeDefined();
expect(filledFields[formBPasswordField.opid]).toBeUndefined();
});
});
describe("given current password update with inlineMenuFillType", () => {
beforeEach(() => {
pageDetails.forms = undefined;
pageDetails.fields = []; // Clear fields to start fresh
options.inlineMenuFillType = InlineMenuFillTypes.CurrentPasswordUpdate;
options.cipher.login.totp = null; // Disable TOTP for these tests
});
it("includes all password fields from the same form when updating current password", async () => {
const currentPasswordField = createAutofillFieldMock({
opid: "current-password",
type: "password",
form: "validFormId",
elementNumber: 1,
});
const newPasswordField = createAutofillFieldMock({
opid: "new-password",
type: "password",
form: "validFormId",
elementNumber: 2,
});
const confirmPasswordField = createAutofillFieldMock({
opid: "confirm-password",
type: "password",
form: "validFormId",
elementNumber: 3,
});
pageDetails.fields.push(currentPasswordField, newPasswordField, confirmPasswordField);
options.focusedFieldOpid = currentPasswordField.opid;
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(filledFields[currentPasswordField.opid]).toBeDefined();
expect(filledFields[newPasswordField.opid]).toBeDefined();
expect(filledFields[confirmPasswordField.opid]).toBeDefined();
});
it("includes all password fields from the same form without TOTP", async () => {
const currentPasswordField = createAutofillFieldMock({
opid: "current-password",
type: "password",
form: "validFormId",
elementNumber: 1,
});
const newPasswordField = createAutofillFieldMock({
opid: "new-password",
type: "password",
form: "validFormId",
elementNumber: 2,
});
pageDetails.fields.push(currentPasswordField, newPasswordField);
options.focusedFieldOpid = currentPasswordField.opid;
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(filledFields[currentPasswordField.opid]).toBeDefined();
expect(filledFields[newPasswordField.opid]).toBeDefined();
});
it("does not include password fields from different forms during password update", async () => {
const formAPasswordField = createAutofillFieldMock({
opid: "form-a-password",
type: "password",
form: "formA",
elementNumber: 1,
});
const formBPasswordField = createAutofillFieldMock({
opid: "form-b-password",
type: "password",
form: "formB",
elementNumber: 2,
});
pageDetails.fields = [formAPasswordField, formBPasswordField];
options.focusedFieldOpid = formAPasswordField.opid;
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(filledFields[formAPasswordField.opid]).toBeDefined();
expect(filledFields[formBPasswordField.opid]).toBeUndefined();
});
});
describe("given a set of page details that does not contain a password field", () => {
let emailField: AutofillField;
let emailFieldView: FieldView;
@@ -3140,12 +3331,16 @@ describe("AutofillService", () => {
"example.com",
"exampleapp.com",
]);
domainSettingsService.equivalentDomains$ = of([["not-example.com"]]);
const pageUrl = "https://subdomain.example.com";
const tabUrl = "https://www.not-example.com";
const generateFillScriptOptions = createGenerateFillScriptOptionsMock({ tabUrl });
generateFillScriptOptions.cipher.login.matchesUri = jest.fn().mockReturnValueOnce(false);
// Mock getUrlEquivalentDomains to return the expected domains
jest
.spyOn(domainSettingsService, "getUrlEquivalentDomains")
.mockReturnValue(of(equivalentDomains));
const result = await autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions);
expect(generateFillScriptOptions.cipher.login.matchesUri).toHaveBeenCalledWith(

View File

@@ -52,6 +52,7 @@ import { ScriptInjectorService } from "../../platform/services/abstractions/scri
// eslint-disable-next-line no-restricted-imports
import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window";
import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums";
import { InlineMenuFillTypes } from "../enums/autofill-overlay.enum";
import { AutofillPort } from "../enums/autofill-port.enum";
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
@@ -452,6 +453,7 @@ export default class AutofillService implements AutofillServiceInterface {
tabUrl: tab.url,
defaultUriMatch: defaultUriMatch,
focusedFieldOpid: options.focusedFieldOpid,
inlineMenuFillType: options.inlineMenuFillType,
});
if (!fillScript || !fillScript.script || !fillScript.script.length) {
@@ -971,26 +973,53 @@ export default class AutofillService implements AutofillServiceInterface {
if (passwordFields.length && !passwords.length) {
// in the event that password fields exist but weren't processed within form elements.
// select matching password if focused, otherwise first in prioritized list. for username, use focused field if it matches, otherwise find field before password.
const passwordFieldToUse = focusedField
? prioritizedPasswordFields.find(passwordMatchesFocused) || prioritizedPasswordFields[0]
: prioritizedPasswordFields[0];
const isPasswordGeneration =
options.inlineMenuFillType === InlineMenuFillTypes.PasswordGeneration;
const isCurrentPasswordUpdate =
options.inlineMenuFillType === InlineMenuFillTypes.CurrentPasswordUpdate;
if (passwordFieldToUse) {
passwords.push(passwordFieldToUse);
// For password generation or current password update, include all password fields from the same form
// This ensures we have access to all fields regardless of their login/registration classification
if ((isPasswordGeneration || isCurrentPasswordUpdate) && focusedField) {
// Add all password fields from the same form as the focused field
const focusedFieldForm = focusedField.form;
if (login.username && passwordFieldToUse.elementNumber > 0) {
username = getUsernameForPassword(passwordFieldToUse, true);
// Check both login and registration fields to ensure we get all password fields
const allPasswordFields = [...loginPasswordFields, ...registrationPasswordFields];
allPasswordFields.forEach((passField) => {
if (passField.form === focusedFieldForm) {
passwords.push(passField);
}
});
}
// If we didn't add any passwords above (either not password generation/update or no matching fields),
// select matching password if focused, otherwise first in prioritized list.
if (!passwords.length) {
const passwordFieldToUse = focusedField
? prioritizedPasswordFields.find(passwordMatchesFocused) || prioritizedPasswordFields[0]
: prioritizedPasswordFields[0];
if (passwordFieldToUse) {
passwords.push(passwordFieldToUse);
}
}
// Handle username and TOTP for the first password field
const firstPasswordField = passwords[0];
if (firstPasswordField) {
if (login.username && firstPasswordField.elementNumber > 0) {
username = getUsernameForPassword(firstPasswordField, true);
if (username) {
usernames.set(username.opid, username);
}
}
if (options.allowTotpAutofill && login.totp && passwordFieldToUse.elementNumber > 0) {
if (options.allowTotpAutofill && login.totp && firstPasswordField.elementNumber > 0) {
totp =
isFocusedTotpField && passwordMatchesFocused(passwordFieldToUse)
isFocusedTotpField && passwordMatchesFocused(firstPasswordField)
? focusedField
: this.findTotpField(pageDetails, passwordFieldToUse, false, false, true);
: this.findTotpField(pageDetails, firstPasswordField, false, false, true);
if (totp) {
totps.push(totp);
}

View File

@@ -47,8 +47,8 @@
<app-send-list-items-container [headerText]="title | i18n" [sends]="sends$ | async" />
</ng-container>
@if (showSkeletonsLoaders$ | async) {
<vault-fade-in-skeleton>
<vault-fade-in-out-skeleton>
<vault-loading-skeleton></vault-loading-skeleton>
</vault-fade-in-skeleton>
</vault-fade-in-out-skeleton>
}
</popup-page>

View File

@@ -15,6 +15,8 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator";
import {
ButtonModule,
CalloutModule,
@@ -95,8 +97,16 @@ export class SendV2Component implements OnDestroy {
/** Skeleton Loading State */
protected showSkeletonsLoaders$ = combineLatest([
this.sendsLoading$,
this.searchService.isSendSearching$,
this.skeletonFeatureFlag$,
]).pipe(map(([loading, skeletonsEnabled]) => loading && skeletonsEnabled));
]).pipe(
map(
([loading, cipherSearching, skeletonsEnabled]) =>
(loading || cipherSearching) && skeletonsEnabled,
),
distinctUntilChanged(),
skeletonLoadingDelay(),
);
protected title: string = "allSends";
protected noItemIcon = NoSendsIcon;
@@ -110,6 +120,7 @@ export class SendV2Component implements OnDestroy {
private policyService: PolicyService,
private accountService: AccountService,
private configService: ConfigService,
private searchService: SearchService,
) {
combineLatest([
this.sendItemsService.emptyList$,

View File

@@ -0,0 +1 @@
<ng-content></ng-content>

View File

@@ -0,0 +1,20 @@
import { animate, style, transition, trigger } from "@angular/animations";
import { ChangeDetectionStrategy, Component, HostBinding } from "@angular/core";
@Component({
selector: "vault-fade-in-out",
templateUrl: "./vault-fade-in-out.component.html",
animations: [
trigger("fadeInOut", [
transition(":enter", [
style({ opacity: 0 }),
animate("100ms ease-in", style({ opacity: 1 })),
]),
transition(":leave", [animate("300ms ease-out", style({ opacity: 0 }))]),
]),
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VaultFadeInOutComponent {
@HostBinding("@fadeInOut") fadeInOut = true;
}

View File

@@ -8,20 +8,32 @@
</ng-container>
</popup-header>
<div
*ngIf="vaultState === VaultStateEnum.Empty"
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
>
<bit-no-items [icon]="vaultIcon">
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
<ng-container slot="description">
<p bitTypography="body2" class="tw-mx-6 tw-mt-2">{{ "emptyVaultDescription" | i18n }}</p>
</ng-container>
<a slot="button" bitButton buttonType="secondary" [routerLink]="['/add-cipher']">
{{ "newLogin" | i18n }}
</a>
</bit-no-items>
</div>
<ng-template #emptyVaultTemplate>
<div
*ngIf="vaultState === VaultStateEnum.Empty"
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
>
<bit-no-items [icon]="vaultIcon">
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
<ng-container slot="description">
<p bitTypography="body2" class="tw-mx-6 tw-mt-2">
{{ "emptyVaultDescription" | i18n }}
</p>
</ng-container>
<a slot="button" bitButton buttonType="secondary" [routerLink]="['/add-cipher']">
{{ "newLogin" | i18n }}
</a>
</bit-no-items>
</div>
</ng-template>
@if (skeletonFeatureFlag$ | async) {
<vault-fade-in-out *ngIf="vaultState === VaultStateEnum.Empty">
<ng-container *ngTemplateOutlet="emptyVaultTemplate"></ng-container>
</vault-fade-in-out>
} @else {
<ng-container *ngTemplateOutlet="emptyVaultTemplate"></ng-container>
}
<blocked-injection-banner
*ngIf="vaultState !== VaultStateEnum.Empty"
@@ -95,22 +107,32 @@
</div>
</div>
<ng-container *ngIf="vaultState === null">
<app-autofill-vault-list-items></app-autofill-vault-list-items>
<app-vault-list-items-container
[title]="'favorites' | i18n"
[ciphers]="(favoriteCiphers$ | async) || []"
id="favorites"
collapsibleKey="favorites"
></app-vault-list-items-container>
<app-vault-list-items-container
[title]="'allItems' | i18n"
[ciphers]="(remainingCiphers$ | async) || []"
id="allItems"
disableSectionMargin
collapsibleKey="allItems"
></app-vault-list-items-container>
</ng-container>
<ng-template #vaultContentTemplate>
<ng-container *ngIf="vaultState === null">
<app-autofill-vault-list-items></app-autofill-vault-list-items>
<app-vault-list-items-container
[title]="'favorites' | i18n"
[ciphers]="(favoriteCiphers$ | async) || []"
id="favorites"
collapsibleKey="favorites"
></app-vault-list-items-container>
<app-vault-list-items-container
[title]="'allItems' | i18n"
[ciphers]="(remainingCiphers$ | async) || []"
id="allItems"
disableSectionMargin
collapsibleKey="allItems"
></app-vault-list-items-container>
</ng-container>
</ng-template>
@if (skeletonFeatureFlag$ | async) {
<vault-fade-in-out *ngIf="vaultState === null">
<ng-container *ngTemplateOutlet="vaultContentTemplate"></ng-container>
</vault-fade-in-out>
} @else {
<ng-container *ngTemplateOutlet="vaultContentTemplate"></ng-container>
}
</ng-container>
@if (showSkeletonsLoaders$ | async) {

View File

@@ -23,6 +23,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { DialogService } from "@bitwarden/components";
@@ -259,6 +260,10 @@ describe("VaultV2Component", () => {
getFeatureFlag$: (_: string) => of(false),
},
},
{
provide: SearchService,
useValue: { isCipherSearching$: of(false) },
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

View File

@@ -32,8 +32,10 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator";
import {
ButtonModule,
DialogService,
@@ -54,6 +56,7 @@ import { VaultPopupListFiltersService } from "../../services/vault-popup-list-fi
import { VaultPopupLoadingService } from "../../services/vault-popup-loading.service";
import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service";
import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component";
import { VaultFadeInOutComponent } from "../vault-fade-in-out/vault-fade-in-out.component";
import { VaultFadeInOutSkeletonComponent } from "../vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component";
import { VaultLoadingSkeletonComponent } from "../vault-loading-skeleton/vault-loading-skeleton.component";
@@ -100,6 +103,7 @@ type VaultState = UnionOfValues<typeof VaultState>;
TypographyModule,
VaultLoadingSkeletonComponent,
VaultFadeInOutSkeletonComponent,
VaultFadeInOutComponent,
],
})
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
@@ -129,7 +133,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
}),
);
private skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
protected skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
FeatureFlag.VaultLoadingSkeletons,
);
@@ -183,9 +187,18 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled),
);
/** When true, show skeleton loading state */
protected showSkeletonsLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe(
map(([loading, skeletonsEnabled]) => loading && skeletonsEnabled),
/** When true, show skeleton loading state with debouncing to prevent flicker */
protected showSkeletonsLoaders$ = combineLatest([
this.loading$,
this.searchService.isCipherSearching$,
this.skeletonFeatureFlag$,
]).pipe(
map(
([loading, cipherSearching, skeletonsEnabled]) =>
(loading || cipherSearching) && skeletonsEnabled,
),
distinctUntilChanged(),
skeletonLoadingDelay(),
);
protected newItemItemValues$: Observable<NewItemInitialValues> =
@@ -228,6 +241,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
private liveAnnouncer: LiveAnnouncer,
private i18nService: I18nService,
private configService: ConfigService,
private searchService: SearchService,
) {
combineLatest([
this.vaultPopupItemsService.emptyVault$,

View File

@@ -75,7 +75,7 @@
"inquirer": "8.2.6",
"jsdom": "26.1.0",
"jszip": "3.10.1",
"koa": "2.16.1",
"koa": "2.16.2",
"koa-bodyparser": "4.4.1",
"koa-json": "2.0.2",
"lowdb": "1.0.0",

View File

@@ -1,5 +1,5 @@
<p>
{{ "organizationDataOwnershipContent" | i18n }}
{{ "organizationDataOwnershipDescContent" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"

View File

@@ -25,23 +25,21 @@
<li>{{ "twoFactorYubikeySaveForm" | i18n }}</li>
</ol>
<hr />
<div class="tw-grid tw-grid-cols-12 tw-gap-4" formArrayName="formKeys">
<div class="tw-col-span-6" *ngFor="let k of keys; let i = index">
<div [formGroupName]="i">
<bit-label>{{ "yubikeyX" | i18n: (i + 1).toString() }}</bit-label>
<bit-form-field *ngIf="!keys[i].existingKey">
<input bitInput type="password" formControlName="key" appInputVerbatim />
</bit-form-field>
<div class="tw-flex tw-justify-between tw-mb-6" *ngIf="keys[i].existingKey">
<span class="tw-mr-2 tw-self-center">{{ keys[i].existingKey }}</span>
<button
bitIconButton="bwi-minus-circle"
type="button"
buttonType="danger"
(click)="remove(i)"
label="{{ 'remove' | i18n }}"
></button>
</div>
<div class="tw-flex tw-flex-col tw-mt-4" formArrayName="formKeys">
<div *ngFor="let k of keys; let i = index" [formGroupName]="i">
<bit-label>{{ "yubikeyX" | i18n: (i + 1).toString() }}</bit-label>
<bit-form-field *ngIf="!keys[i].existingKey">
<input bitInput type="password" formControlName="key" appInputVerbatim />
</bit-form-field>
<div class="tw-flex tw-justify-between tw-mb-4" *ngIf="keys[i].existingKey">
<span class="tw-mr-2 tw-self-center">{{ keys[i].existingKey }}</span>
<button
bitIconButton="bwi-minus-circle"
type="button"
buttonType="danger"
(click)="remove(i)"
label="{{ 'remove' | i18n }}"
></button>
</div>
</div>
</div>

View File

@@ -344,6 +344,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
data: {
type: "Organization",
id: this.organizationId,
plan: this.sub.plan.type,
},
});

View File

@@ -21,7 +21,8 @@
</bit-label>
<textarea rows="4" bitInput formControlName="feedback"></textarea>
<bit-hint>{{
"charactersCurrentAndMaximum" | i18n: formGroup.value.feedback.length : MaxFeedbackLength
"charactersCurrentAndMaximum"
| i18n: formGroup.value.feedback?.length ?? 0 : MaxFeedbackLength
}}</bit-hint>
</bit-form-field>
</div>

View File

@@ -1,9 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Inject } from "@angular/core";
import { ChangeDetectionStrategy, Component, Inject } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { PlanType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
@@ -21,6 +22,7 @@ type UserOffboardingParams = {
type OrganizationOffboardingParams = {
type: "Organization";
id: string;
plan: PlanType;
};
export type OffboardingSurveyDialogParams = UserOffboardingParams | OrganizationOffboardingParams;
@@ -46,50 +48,20 @@ export const openOffboardingSurvey = (
dialogConfig,
);
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-cancel-subscription-form",
templateUrl: "offboarding-survey.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class OffboardingSurveyComponent {
protected ResultType = OffboardingSurveyDialogResultType;
protected readonly MaxFeedbackLength = 400;
protected readonly reasons: Reason[] = [
{
value: null,
text: this.i18nService.t("selectPlaceholder"),
},
{
value: "missing_features",
text: this.i18nService.t("missingFeatures"),
},
{
value: "switched_service",
text: this.i18nService.t("movingToAnotherTool"),
},
{
value: "too_complex",
text: this.i18nService.t("tooDifficultToUse"),
},
{
value: "unused",
text: this.i18nService.t("notUsingEnough"),
},
{
value: "too_expensive",
text: this.i18nService.t("tooExpensive"),
},
{
value: "other",
text: this.i18nService.t("other"),
},
];
protected readonly reasons: Reason[] = [];
protected formGroup = this.formBuilder.group({
reason: [this.reasons[0].value, [Validators.required]],
reason: [null, [Validators.required]],
feedback: ["", [Validators.maxLength(this.MaxFeedbackLength)]],
});
@@ -101,7 +73,35 @@ export class OffboardingSurveyComponent {
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private toastService: ToastService,
) {}
) {
this.reasons = [
{
value: null,
text: this.i18nService.t("selectPlaceholder"),
},
{
value: "missing_features",
text: this.i18nService.t("missingFeatures"),
},
{
value: "switched_service",
text: this.i18nService.t("movingToAnotherTool"),
},
{
value: "too_complex",
text: this.i18nService.t("tooDifficultToUse"),
},
{
value: "unused",
text: this.i18nService.t("notUsingEnough"),
},
this.getSwitchingReason(),
{
value: "other",
text: this.i18nService.t("other"),
},
];
}
submit = async () => {
this.formGroup.markAllAsTouched();
@@ -127,4 +127,24 @@ export class OffboardingSurveyComponent {
this.dialogRef.close(this.ResultType.Submitted);
};
private getSwitchingReason(): Reason {
if (this.dialogParams.type === "User") {
return {
value: "too_expensive",
text: this.i18nService.t("switchToFreePlan"),
};
}
const isFamilyPlan = [
PlanType.FamiliesAnnually,
PlanType.FamiliesAnnually2019,
PlanType.FamiliesAnnually2025,
].includes(this.dialogParams.plan);
return {
value: "too_expensive",
text: this.i18nService.t(isFamilyPlan ? "switchToFreeOrg" : "tooExpensive"),
};
}
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, OnDestroy } from "@angular/core";
import {
BehaviorSubject,
@@ -36,7 +34,7 @@ import {
import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service";
@Directive()
export class CipherReportComponent implements OnDestroy {
export abstract class CipherReportComponent implements OnDestroy {
isAdminConsoleActive = false;
loading = false;
@@ -44,16 +42,16 @@ export class CipherReportComponent implements OnDestroy {
ciphers: CipherView[] = [];
allCiphers: CipherView[] = [];
dataSource = new TableDataSource<CipherView>();
organization: Organization;
organizations: Organization[];
organization: Organization | undefined = undefined;
organizations: Organization[] = [];
organizations$: Observable<Organization[]>;
filterStatus: any = [0];
showFilterToggle: boolean = false;
vaultMsg: string = "vault";
currentFilterStatus: number | string;
currentFilterStatus: number | string = 0;
protected filterOrgStatus$ = new BehaviorSubject<number | string>(0);
private destroyed$: Subject<void> = new Subject();
protected destroyed$: Subject<void> = new Subject();
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
constructor(
@@ -107,7 +105,7 @@ export class CipherReportComponent implements OnDestroy {
if (filterId === 0) {
cipherCount = this.allCiphers.length;
} else if (filterId === 1) {
cipherCount = this.allCiphers.filter((c) => c.organizationId === null).length;
cipherCount = this.allCiphers.filter((c) => !c.organizationId).length;
} else {
this.organizations.filter((org: Organization) => {
if (org.id === filterId) {
@@ -121,9 +119,9 @@ export class CipherReportComponent implements OnDestroy {
}
async filterOrgToggle(status: any) {
let filter = null;
let filter = (c: CipherView) => true;
if (typeof status === "number" && status === 1) {
filter = (c: CipherView) => c.organizationId == null;
filter = (c: CipherView) => !c.organizationId;
} else if (typeof status === "string") {
const orgId = status as OrganizationId;
filter = (c: CipherView) => c.organizationId === orgId;
@@ -185,7 +183,7 @@ export class CipherReportComponent implements OnDestroy {
cipher: CipherView,
activeCollectionId?: CollectionId,
) {
const disableForm = cipher ? !cipher.edit && !this.organization.canEditAllCiphers : false;
const disableForm = cipher ? !cipher.edit && !this.organization?.canEditAllCiphers : false;
this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, {
mode,
@@ -230,10 +228,11 @@ export class CipherReportComponent implements OnDestroy {
let updatedCipher = await this.cipherService.get(cipher.id, activeUserId);
if (this.isAdminConsoleActive) {
updatedCipher = await this.adminConsoleCipherFormConfigService.getCipher(
cipher.id as CipherId,
this.organization,
);
updatedCipher =
(await this.adminConsoleCipherFormConfigService.getCipher(
cipher.id as CipherId,
this.organization!,
)) ?? updatedCipher;
}
// convert cipher to cipher view model

View File

@@ -90,6 +90,7 @@ describe("ExposedPasswordsReportComponent", () => {
});
beforeEach(() => {
jest.clearAllMocks();
fixture = TestBed.createComponent(ExposedPasswordsReportComponent);
component = fixture.componentInstance;
fixture.detectChanges();

View File

@@ -1,3 +1,4 @@
import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
@@ -29,14 +30,13 @@ describe("InactiveTwoFactorReportComponent", () => {
const userId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(userId);
beforeEach(() => {
beforeEach(async () => {
let cipherFormConfigServiceMock: MockProxy<CipherFormConfigService>;
organizationService = mock<OrganizationService>();
organizationService.organizations$.mockReturnValue(of([]));
syncServiceMock = mock<SyncService>();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
await TestBed.configureTestingModule({
declarations: [InactiveTwoFactorReportComponent, I18nPipe],
providers: [
{
@@ -80,9 +80,7 @@ describe("InactiveTwoFactorReportComponent", () => {
useValue: adminConsoleCipherFormConfigServiceMock,
},
],
schemas: [],
// FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports
errorOnUnknownElements: false,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
});

View File

@@ -1,6 +1,4 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from "@angular/core";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -19,9 +17,8 @@ import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/se
import { CipherReportComponent } from "./cipher-report.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-inactive-two-factor-report",
templateUrl: "inactive-two-factor-report.component.html",
standalone: false,
@@ -42,6 +39,7 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
syncService: SyncService,
cipherFormConfigService: CipherFormConfigService,
adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService,
protected changeDetectorRef: ChangeDetectorRef,
) {
super(
cipherService,
@@ -86,6 +84,7 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
this.filterCiphersByOrg(inactive2faCiphers);
this.cipherDocs = docs;
this.changeDetectorRef.markForCheck();
}
}
@@ -157,6 +156,7 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
}
this.services.set(serviceData.domain, serviceData.documentation);
}
this.changeDetectorRef.markForCheck();
}
/**

View File

@@ -1,16 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { ChangeDetectorRef, Component, OnInit, ChangeDetectionStrategy } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom, map, takeUntil } from "rxjs";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { getById } from "@bitwarden/common/platform/misc";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
@@ -23,9 +19,8 @@ import { RoutedVaultFilterService } from "../../../../vault/individual-vault/vau
import { AdminConsoleCipherFormConfigService } from "../../../../vault/org-vault/services/admin-console-cipher-form-config.service";
import { InactiveTwoFactorReportComponent as BaseInactiveTwoFactorReportComponent } from "../inactive-two-factor-report.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-inactive-two-factor-report",
templateUrl: "../inactive-two-factor-report.component.html",
providers: [
@@ -44,7 +39,7 @@ export class InactiveTwoFactorReportComponent
implements OnInit
{
// Contains a list of ciphers, the user running the report, can manage
private manageableCiphers: Cipher[];
private manageableCiphers: Cipher[] = [];
constructor(
cipherService: CipherService,
@@ -58,6 +53,7 @@ export class InactiveTwoFactorReportComponent
syncService: SyncService,
cipherFormConfigService: CipherFormConfigService,
adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService,
protected changeDetectorRef: ChangeDetectorRef,
) {
super(
cipherService,
@@ -70,28 +66,37 @@ export class InactiveTwoFactorReportComponent
syncService,
cipherFormConfigService,
adminConsoleCipherFormConfigService,
changeDetectorRef,
);
}
async ngOnInit() {
this.isAdminConsoleActive = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
this.organization = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(params.organizationId)),
);
this.manageableCiphers = await this.cipherService.getAll(userId);
await super.ngOnInit();
});
this.route.parent?.parent?.params
?.pipe(takeUntil(this.destroyed$))
// eslint-disable-next-line rxjs/no-async-subscribe
.subscribe(async (params) => {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (userId) {
this.organization = await firstValueFrom(
this.organizationService.organizations$(userId).pipe(getById(params.organizationId)),
);
this.manageableCiphers = await this.cipherService.getAll(userId);
await super.ngOnInit();
}
this.changeDetectorRef.markForCheck();
});
}
getAllCiphers(): Promise<CipherView[]> {
return this.cipherService.getAllFromApiForOrganization(this.organization.id);
async getAllCiphers(): Promise<CipherView[]> {
if (this.organization) {
return await this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
}
return [];
}
protected canManageCipher(c: CipherView): boolean {

View File

@@ -5813,9 +5813,9 @@
"message": "Require all items to be owned by an organization, removing the option to store items at the account level.",
"description": "This is the policy description shown in the policy list."
},
"organizationDataOwnershipContent": {
"message": "All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the ",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the credential lifecycle.'"
"organizationDataOwnershipDescContent": {
"message": "All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection will be available for each member to store items. Learn more about managing the ",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection will be available for each member to store items. Learn more about managing the credential lifecycle.'"
},
"organizationDataOwnershipContentAnchor": {
"message": "credential lifecycle",
@@ -9824,6 +9824,14 @@
"message": "Too expensive",
"description": "An option for the offboarding survey shown when a user cancels their subscription."
},
"switchToFreePlan": {
"message": "Switching to free plan",
"description": "An option for the offboarding survey shown when a user cancels their subscription."
},
"switchToFreeOrg": {
"message": "Switching to free organization",
"description": "An option for the offboarding survey shown when a user cancels their subscription."
},
"freeForOneYear": {
"message": "Free for 1 year"
},
@@ -12122,6 +12130,15 @@
"startFreeFamiliesTrial": {
"message": "Start free Families trial"
},
"blockClaimedDomainAccountCreation": {
"message": "Block account creation for claimed domains"
},
"blockClaimedDomainAccountCreationDesc": {
"message": "Prevent users from creating accounts outside of your organization using email addresses from claimed domains."
},
"blockClaimedDomainAccountCreationPrerequisite": {
"message": "A domain must be claimed before activating this policy."
},
"unlockMethodNeededToChangeTimeoutActionDesc": {
"message": "Set up an unlock method to change your vault timeout action."
},

View File

@@ -0,0 +1,15 @@
<bit-callout type="info" title="{{ 'prerequisite' | i18n }}">
{{ "blockClaimedDomainAccountCreationPrerequisite" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/domain-verification/"
target="_blank"
rel="noreferrer"
>{{ "learnMore" | i18n }}</a
>
</bit-callout>
<bit-form-control>
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>

View File

@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { map, Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
BasePolicyEditDefinition,
BasePolicyEditComponent,
} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
export class BlockClaimedDomainAccountCreationPolicy extends BasePolicyEditDefinition {
name = "blockClaimedDomainAccountCreation";
description = "blockClaimedDomainAccountCreationDesc";
type = PolicyType.BlockClaimedDomainAccountCreation;
component = BlockClaimedDomainAccountCreationPolicyComponent;
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
.getFeatureFlag$(FeatureFlag.BlockClaimedDomainAccountCreation)
.pipe(map((enabled) => enabled && organization.useOrganizationDomains));
}
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "block-claimed-domain-account-creation.component.html",
imports: [SharedModule],
})
export class BlockClaimedDomainAccountCreationPolicyComponent extends BasePolicyEditComponent {}

View File

@@ -1,3 +1,4 @@
export { ActivateAutofillPolicy } from "./activate-autofill.component";
export { AutomaticAppLoginPolicy } from "./automatic-app-login.component";
export { BlockClaimedDomainAccountCreationPolicy } from "./block-claimed-domain-account-creation.component";
export { DisablePersonalVaultExportPolicy } from "./disable-personal-vault-export.component";

View File

@@ -9,6 +9,7 @@ import { SessionTimeoutPolicy } from "../../key-management/policies/session-time
import {
ActivateAutofillPolicy,
AutomaticAppLoginPolicy,
BlockClaimedDomainAccountCreationPolicy,
DisablePersonalVaultExportPolicy,
} from "./policy-edit-definitions";
@@ -23,6 +24,7 @@ const policyEditRegister: BasePolicyEditDefinition[] = [
new FreeFamiliesSponsorshipPolicy(),
new ActivateAutofillPolicy(),
new AutomaticAppLoginPolicy(),
new BlockClaimedDomainAccountCreationPolicy(),
];
export const bitPolicyEditRegister = ossPolicyEditRegister.concat(policyEditRegister);

View File

@@ -20,4 +20,5 @@ export enum PolicyType {
UriMatchDefaults = 16, // Sets the default URI matching strategy for all users within an organization
AutotypeDefaultSetting = 17, // Sets the default autotype setting for desktop app
AutoConfirm = 18, // Enables the auto confirmation feature for admins to enable in their client
BlockClaimedDomainAccountCreation = 19, // Prevents users from creating personal accounts using email addresses from verified domains
}

View File

@@ -13,6 +13,7 @@ export enum FeatureFlag {
/* Admin Console Team */
CreateDefaultLocation = "pm-19467-create-default-location",
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "block-claimed-domain-account-creation",
/* Auth */
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
@@ -91,6 +92,7 @@ export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.CreateDefaultLocation]: FALSE,
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,

View File

@@ -6,6 +6,9 @@ import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike } from "../utils/cipher-view-like-utils";
export abstract class SearchService {
abstract isCipherSearching$: Observable<boolean>;
abstract isSendSearching$: Observable<boolean>;
abstract indexedEntityId$(userId: UserId): Observable<IndexedEntityId | null>;
abstract clearIndex(userId: UserId): Promise<void>;

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as lunr from "lunr";
import { Observable, firstValueFrom, map } from "rxjs";
import { BehaviorSubject, Observable, firstValueFrom, map } from "rxjs";
import { Jsonify } from "type-fest";
import { perUserCache$ } from "@bitwarden/common/vault/utils/observable-utilities";
@@ -81,6 +81,12 @@ export class SearchService implements SearchServiceAbstraction {
private readonly defaultSearchableMinLength: number = 2;
private searchableMinLength: number = this.defaultSearchableMinLength;
private _isCipherSearching$ = new BehaviorSubject<boolean>(false);
isCipherSearching$: Observable<boolean> = this._isCipherSearching$.asObservable();
private _isSendSearching$ = new BehaviorSubject<boolean>(false);
isSendSearching$: Observable<boolean> = this._isSendSearching$.asObservable();
constructor(
private logService: LogService,
private i18nService: I18nService,
@@ -223,6 +229,7 @@ export class SearchService implements SearchServiceAbstraction {
filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null,
ciphers: C[],
): Promise<C[]> {
this._isCipherSearching$.next(true);
const results: C[] = [];
const searchStartTime = performance.now();
if (query != null) {
@@ -243,6 +250,7 @@ export class SearchService implements SearchServiceAbstraction {
}
if (!(await this.isSearchable(userId, query))) {
this._isCipherSearching$.next(false);
return ciphers;
}
@@ -258,6 +266,7 @@ export class SearchService implements SearchServiceAbstraction {
// Fall back to basic search if index is not available
const basicResults = this.searchCiphersBasic(ciphers, query);
this.logService.measure(searchStartTime, "Vault", "SearchService", "basic search complete");
this._isCipherSearching$.next(false);
return basicResults;
}
@@ -293,6 +302,7 @@ export class SearchService implements SearchServiceAbstraction {
});
}
this.logService.measure(searchStartTime, "Vault", "SearchService", "search complete");
this._isCipherSearching$.next(false);
return results;
}
@@ -335,8 +345,10 @@ export class SearchService implements SearchServiceAbstraction {
}
searchSends(sends: SendView[], query: string) {
this._isSendSearching$.next(true);
query = SearchService.normalizeSearchQuery(query.trim().toLocaleLowerCase());
if (query === null) {
this._isSendSearching$.next(false);
return sends;
}
const sendsMatched: SendView[] = [];
@@ -359,6 +371,7 @@ export class SearchService implements SearchServiceAbstraction {
lowPriorityMatched.push(s);
}
});
this._isSendSearching$.next(false);
return sendsMatched.concat(lowPriorityMatched);
}

View File

@@ -0,0 +1,109 @@
import { BehaviorSubject } from "rxjs";
import { skeletonLoadingDelay } from "./skeleton-loading.operator";
describe("skeletonLoadingDelay", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
it("returns false immediately when starting with false", () => {
const source$ = new BehaviorSubject<boolean>(false);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
expect(results).toEqual([false]);
});
it("waits 1 second before returning true when starting with true", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
expect(results).toEqual([]);
jest.advanceTimersByTime(999);
expect(results).toEqual([]);
jest.advanceTimersByTime(1);
expect(results).toEqual([true]);
});
it("cancels if source becomes false before show delay completes", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
jest.advanceTimersByTime(500);
source$.next(false);
expect(results).toEqual([false]);
jest.advanceTimersByTime(1000);
expect(results).toEqual([false]);
});
it("delays hiding if minimum display time has not elapsed", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
jest.advanceTimersByTime(1000);
expect(results).toEqual([true]);
source$.next(false);
expect(results).toEqual([true]);
jest.advanceTimersByTime(1000);
expect(results).toEqual([true, false]);
});
it("handles rapid true->false->true transitions", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
jest.advanceTimersByTime(500);
expect(results).toEqual([]);
source$.next(false);
expect(results).toEqual([false]);
source$.next(true);
jest.advanceTimersByTime(999);
expect(results).toEqual([false]);
jest.advanceTimersByTime(1);
expect(results).toEqual([false, true]);
});
it("allows for custom timings", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay(1000, 2000)).subscribe((value) => results.push(value));
jest.advanceTimersByTime(1000);
expect(results).toEqual([true]);
source$.next(false);
jest.advanceTimersByTime(1999);
expect(results).toEqual([true]);
jest.advanceTimersByTime(1);
expect(results).toEqual([true, false]);
});
});

View File

@@ -0,0 +1,59 @@
import { defer, Observable, of, timer } from "rxjs";
import { map, switchMap, tap } from "rxjs/operators";
/**
* RxJS operator that adds skeleton loading delay behavior.
*
* - Waits 1 second before showing (prevents flashing for quick loads)
* - Ensures skeleton stays visible for at least 1 second once shown regardless of the source observable emissions
* - After the minimum display time, if the source is still true, continues to emit true until the source becomes false
* - False can only be emitted either:
* - Immediately when the source emits false before the skeleton is shown
* - After the minimum display time has passed once the skeleton is shown
*/
export function skeletonLoadingDelay(
showDelay = 1000,
minDisplayTime = 1000,
): (source: Observable<boolean>) => Observable<boolean> {
return (source: Observable<boolean>) => {
return defer(() => {
let skeletonShownAt: number | null = null;
return source.pipe(
switchMap((shouldShow): Observable<boolean> => {
if (shouldShow) {
if (skeletonShownAt !== null) {
return of(true); // Already shown, continue showing
}
// Wait for delay, then mark the skeleton as shown and emit true
return timer(showDelay).pipe(
tap(() => {
skeletonShownAt = Date.now();
}),
map(() => true),
);
} else {
if (skeletonShownAt === null) {
// Skeleton not shown yet, can emit false immediately
return of(false);
}
// Skeleton shown, ensure minimum display time has passed
const elapsedTime = Date.now() - skeletonShownAt;
const remainingTime = Math.max(0, minDisplayTime - elapsedTime);
// Wait for remaining time to ensure minimum display time
return timer(remainingTime).pipe(
tap(() => {
// Reset the shown timestamp
skeletonShownAt = null;
}),
map(() => false),
);
}
}),
);
});
};
}

10
package-lock.json generated
View File

@@ -49,7 +49,7 @@
"inquirer": "8.2.6",
"jsdom": "26.1.0",
"jszip": "3.10.1",
"koa": "2.16.1",
"koa": "2.16.2",
"koa-bodyparser": "4.4.1",
"koa-json": "2.0.2",
"lit": "3.3.0",
@@ -213,7 +213,7 @@
"inquirer": "8.2.6",
"jsdom": "26.1.0",
"jszip": "3.10.1",
"koa": "2.16.1",
"koa": "2.16.2",
"koa-bodyparser": "4.4.1",
"koa-json": "2.0.2",
"lowdb": "1.0.0",
@@ -27947,9 +27947,9 @@
}
},
"node_modules/koa": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/koa/-/koa-2.16.1.tgz",
"integrity": "sha512-umfX9d3iuSxTQP4pnzLOz0HKnPg0FaUUIKcye2lOiz3KPu1Y3M3xlz76dISdFPQs37P9eJz1wUpcTS6KDPn9fA==",
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/koa/-/koa-2.16.2.tgz",
"integrity": "sha512-+CCssgnrWKx9aI3OeZwroa/ckG4JICxvIFnSiOUyl2Uv+UTI+xIw0FfFrWS7cQFpoePpr9o8csss7KzsTzNL8Q==",
"license": "MIT",
"dependencies": {
"accepts": "^1.3.5",

View File

@@ -186,7 +186,7 @@
"inquirer": "8.2.6",
"jsdom": "26.1.0",
"jszip": "3.10.1",
"koa": "2.16.1",
"koa": "2.16.2",
"koa-bodyparser": "4.4.1",
"koa-json": "2.0.2",
"lit": "3.3.0",