diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 80e453e9e83..50fb291b121 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -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, }); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index f3278fa6b07..225cbbe66ca 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -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 () => { diff --git a/apps/browser/src/autofill/services/abstractions/autofill.service.ts b/apps/browser/src/autofill/services/abstractions/autofill.service.ts index 85bf8c16610..05bfbf378a8 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill.service.ts @@ -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 = { diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 7854dc8e161..817a7cca43c 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -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"); diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index b436214f327..13e97766594 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -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.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( diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index fcc8861228b..010f5ea0f27 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -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); } diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.html b/apps/browser/src/tools/popup/send-v2/send-v2.component.html index 0bcbd47a145..47ecd7564dc 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.html @@ -47,8 +47,8 @@ @if (showSkeletonsLoaders$ | async) { - + - + } diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index 43a1119deca..e3baba53c42 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -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$, diff --git a/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.html b/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.html new file mode 100644 index 00000000000..6dbc7430638 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.html @@ -0,0 +1 @@ + diff --git a/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.ts b/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.ts new file mode 100644 index 00000000000..a30a447833b --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.ts @@ -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; +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index faaa6a40e98..7a5a99c8100 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -8,20 +8,32 @@ -
- - {{ "yourVaultIsEmpty" | i18n }} - -

{{ "emptyVaultDescription" | i18n }}

-
- - {{ "newLogin" | i18n }} - -
-
+ +
+ + {{ "yourVaultIsEmpty" | i18n }} + +

+ {{ "emptyVaultDescription" | i18n }} +

+
+ + {{ "newLogin" | i18n }} + +
+
+
+ + @if (skeletonFeatureFlag$ | async) { + + + + } @else { + + } - - - - - + + + + + + + + + @if (skeletonFeatureFlag$ | async) { + + + + } @else { + + } @if (showSkeletonsLoaders$ | async) { diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index 563ec5f9709..5563cd3033b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -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(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 499e9b76757..471e6e70601 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -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; 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 = @@ -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$, diff --git a/apps/cli/package.json b/apps/cli/package.json index 00686959ef0..fc38440b70f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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", diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html index 0abc40da683..bd2237bc2fd 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html @@ -1,5 +1,5 @@

- {{ "organizationDataOwnershipContent" | i18n }} + {{ "organizationDataOwnershipDescContent" | i18n }} {{ "twoFactorYubikeySaveForm" | i18n }}


-
-
-
- {{ "yubikeyX" | i18n: (i + 1).toString() }} - - - -
- {{ keys[i].existingKey }} - -
+
+
+ {{ "yubikeyX" | i18n: (i + 1).toString() }} + + + +
+ {{ keys[i].existingKey }} +
diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 70e16ad3037..e0c1a12a80f 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -344,6 +344,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy data: { type: "Organization", id: this.organizationId, + plan: this.sub.plan.type, }, }); diff --git a/apps/web/src/app/billing/shared/offboarding-survey.component.html b/apps/web/src/app/billing/shared/offboarding-survey.component.html index b69565d95fa..50cf71a03d5 100644 --- a/apps/web/src/app/billing/shared/offboarding-survey.component.html +++ b/apps/web/src/app/billing/shared/offboarding-survey.component.html @@ -21,7 +21,8 @@ {{ - "charactersCurrentAndMaximum" | i18n: formGroup.value.feedback.length : MaxFeedbackLength + "charactersCurrentAndMaximum" + | i18n: formGroup.value.feedback?.length ?? 0 : MaxFeedbackLength }}
diff --git a/apps/web/src/app/billing/shared/offboarding-survey.component.ts b/apps/web/src/app/billing/shared/offboarding-survey.component.ts index fe7d724a079..40e1572a3bb 100644 --- a/apps/web/src/app/billing/shared/offboarding-survey.component.ts +++ b/apps/web/src/app/billing/shared/offboarding-survey.component.ts @@ -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"), + }; + } } diff --git a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts index 69dd360ad31..d098be56663 100644 --- a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts @@ -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(); - organization: Organization; - organizations: Organization[]; + organization: Organization | undefined = undefined; + organizations: Organization[] = []; organizations$: Observable; filterStatus: any = [0]; showFilterToggle: boolean = false; vaultMsg: string = "vault"; - currentFilterStatus: number | string; + currentFilterStatus: number | string = 0; protected filterOrgStatus$ = new BehaviorSubject(0); - private destroyed$: Subject = new Subject(); + protected destroyed$: Subject = new Subject(); private vaultItemDialogRef?: DialogRef | 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 diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.spec.ts index 052e3bc7cfe..560245bdc34 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.spec.ts @@ -90,6 +90,7 @@ describe("ExposedPasswordsReportComponent", () => { }); beforeEach(() => { + jest.clearAllMocks(); fixture = TestBed.createComponent(ExposedPasswordsReportComponent); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts index 80893737ffd..64a851e120e 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts @@ -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; organizationService = mock(); organizationService.organizations$.mockReturnValue(of([])); syncServiceMock = mock(); - // 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(); }); diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts index 2a8ec12ac6a..9d7de688f3e 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts @@ -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(); } /** diff --git a/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts index fde9c35a6de..17555e617cb 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts @@ -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 { - return this.cipherService.getAllFromApiForOrganization(this.organization.id); + async getAllCiphers(): Promise { + if (this.organization) { + return await this.cipherService.getAllFromApiForOrganization(this.organization.id, true); + } + return []; } protected canManageCipher(c: CipherView): boolean { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 1b0460e2aa6..5cf1bea6fd8 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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." }, diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.html b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.html new file mode 100644 index 00000000000..17225905995 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.html @@ -0,0 +1,15 @@ + + {{ "blockClaimedDomainAccountCreationPrerequisite" | i18n }} +
{{ "learnMore" | i18n }} + + + + + {{ "turnOn" | i18n }} + diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.ts new file mode 100644 index 00000000000..5e2925aa0bb --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.ts @@ -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 { + 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 {} diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/index.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/index.ts index 52325eae160..b03f3680422 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/index.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/index.ts @@ -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"; diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-register.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-register.ts index 015b4fc17be..c2a31d936b8 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-register.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-register.ts @@ -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); diff --git a/libs/common/src/admin-console/enums/policy-type.enum.ts b/libs/common/src/admin-console/enums/policy-type.enum.ts index ae0070dda89..af8147c41e4 100644 --- a/libs/common/src/admin-console/enums/policy-type.enum.ts +++ b/libs/common/src/admin-console/enums/policy-type.enum.ts @@ -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 } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 7d2d831bfb3..d06a14d242f 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -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, diff --git a/libs/common/src/vault/abstractions/search.service.ts b/libs/common/src/vault/abstractions/search.service.ts index 233dee9ec75..29575ec3af9 100644 --- a/libs/common/src/vault/abstractions/search.service.ts +++ b/libs/common/src/vault/abstractions/search.service.ts @@ -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; + abstract isSendSearching$: Observable; + abstract indexedEntityId$(userId: UserId): Observable; abstract clearIndex(userId: UserId): Promise; diff --git a/libs/common/src/vault/services/search.service.ts b/libs/common/src/vault/services/search.service.ts index 80fddda42d5..0b34bd3863f 100644 --- a/libs/common/src/vault/services/search.service.ts +++ b/libs/common/src/vault/services/search.service.ts @@ -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(false); + isCipherSearching$: Observable = this._isCipherSearching$.asObservable(); + + private _isSendSearching$ = new BehaviorSubject(false); + isSendSearching$: Observable = 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 { + 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); } diff --git a/libs/common/src/vault/utils/skeleton-loading.operator.spec.ts b/libs/common/src/vault/utils/skeleton-loading.operator.spec.ts new file mode 100644 index 00000000000..3ba790f64cb --- /dev/null +++ b/libs/common/src/vault/utils/skeleton-loading.operator.spec.ts @@ -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(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(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(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(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(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(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]); + }); +}); diff --git a/libs/common/src/vault/utils/skeleton-loading.operator.ts b/libs/common/src/vault/utils/skeleton-loading.operator.ts new file mode 100644 index 00000000000..b9ff28f64b5 --- /dev/null +++ b/libs/common/src/vault/utils/skeleton-loading.operator.ts @@ -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) => Observable { + return (source: Observable) => { + return defer(() => { + let skeletonShownAt: number | null = null; + + return source.pipe( + switchMap((shouldShow): Observable => { + 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), + ); + } + }), + ); + }); + }; +} diff --git a/package-lock.json b/package-lock.json index b017272cd77..dbb3fdb7e2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 54e2685bbec..5d23d6b9938 100644 --- a/package.json +++ b/package.json @@ -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",