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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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$,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<ng-content></ng-content>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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$,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p>
|
||||
{{ "organizationDataOwnershipContent" | i18n }}
|
||||
{{ "organizationDataOwnershipDescContent" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
href="https://bitwarden.com/resources/credential-lifecycle-management/"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -344,6 +344,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
|
||||
data: {
|
||||
type: "Organization",
|
||||
id: this.organizationId,
|
||||
plan: this.sub.plan.type,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,6 +90,7 @@ describe("ExposedPasswordsReportComponent", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fixture = TestBed.createComponent(ExposedPasswordsReportComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
109
libs/common/src/vault/utils/skeleton-loading.operator.spec.ts
Normal file
109
libs/common/src/vault/utils/skeleton-loading.operator.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
59
libs/common/src/vault/utils/skeleton-loading.operator.ts
Normal file
59
libs/common/src/vault/utils/skeleton-loading.operator.ts
Normal 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
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user