diff --git a/.eslintignore b/.eslintignore
index b6c475d3326..c9a25670a90 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -8,7 +8,6 @@ storybook-static
**/webpack.*.js
**/jest.config.js
-**/gulpfile.js
apps/browser/config/config.js
apps/browser/src/auth/scripts/duo.js
diff --git a/.github/renovate.json b/.github/renovate.json
index 0172403f0f1..446aa789ebf 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -75,11 +75,6 @@
"concurrently",
"cross-env",
"del",
- "gulp",
- "gulp-if",
- "gulp-json-editor",
- "gulp-replace",
- "gulp-zip",
"nord",
"patch-package",
"prettier",
diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml
index 350835d0ec7..6e5e11c3361 100644
--- a/.github/workflows/build-web.yml
+++ b/.github/workflows/build-web.yml
@@ -120,7 +120,6 @@ jobs:
whoami
node --version
npm --version
- gulp --version
docker --version
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index 316bb23c4f8..ab1c6377e6a 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -4888,5 +4888,14 @@
},
"beta": {
"message": "Beta"
+ },
+ "extensionWidth": {
+ "message": "Extension width"
+ },
+ "wide": {
+ "message": "Wide"
+ },
+ "extraWide": {
+ "message": "Extra wide"
}
}
diff --git a/apps/browser/src/platform/popup/browser-popup-utils.ts b/apps/browser/src/platform/popup/browser-popup-utils.ts
index fb53d3451f2..83fde24ac6f 100644
--- a/apps/browser/src/platform/popup/browser-popup-utils.ts
+++ b/apps/browser/src/platform/popup/browser-popup-utils.ts
@@ -1,6 +1,7 @@
import { BrowserApi } from "../browser/browser-api";
import { ScrollOptions } from "./abstractions/browser-popup-utils.abstractions";
+import { PopupWidthOptions } from "./layout/popup-width.service";
class BrowserPopupUtils {
/**
@@ -108,7 +109,7 @@ class BrowserPopupUtils {
const defaultPopoutWindowOptions: chrome.windows.CreateData = {
type: "popup",
focused: true,
- width: 380,
+ width: Math.max(PopupWidthOptions.default, document.body.clientWidth),
height: 630,
};
const offsetRight = 15;
diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts
index e80ac249ac1..1aaea85e4a1 100644
--- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts
+++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts
@@ -514,3 +514,25 @@ export const TransparentHeader: Story = {
`,
}),
};
+
+export const WidthOptions: Story = {
+ render: (args) => ({
+ props: args,
+ template: /* HTML */ `
+
+
Default:
+
+
+
+
Wide:
+
+
+
+
Extra wide:
+
+
+
+
+ `,
+ }),
+};
diff --git a/apps/browser/src/platform/popup/layout/popup-width.service.ts b/apps/browser/src/platform/popup/layout/popup-width.service.ts
new file mode 100644
index 00000000000..cc41b1d9d4a
--- /dev/null
+++ b/apps/browser/src/platform/popup/layout/popup-width.service.ts
@@ -0,0 +1,63 @@
+import { inject, Injectable } from "@angular/core";
+import { map, Observable } from "rxjs";
+
+import {
+ GlobalStateProvider,
+ KeyDefinition,
+ POPUP_STYLE_DISK,
+} from "@bitwarden/common/platform/state";
+
+/**
+ *
+ * Value represents width in pixels
+ */
+export const PopupWidthOptions = Object.freeze({
+ default: 380,
+ wide: 480,
+ "extra-wide": 600,
+});
+
+type PopupWidthOptions = typeof PopupWidthOptions;
+export type PopupWidthOption = keyof PopupWidthOptions;
+
+const POPUP_WIDTH_KEY_DEF = new KeyDefinition(POPUP_STYLE_DISK, "popup-width", {
+ deserializer: (s) => s,
+});
+
+/**
+ * Updates the extension popup width based on a user setting
+ **/
+@Injectable({ providedIn: "root" })
+export class PopupWidthService {
+ private static readonly LocalStorageKey = "bw-popup-width";
+ private readonly state = inject(GlobalStateProvider).get(POPUP_WIDTH_KEY_DEF);
+
+ readonly width$: Observable = this.state.state$.pipe(
+ map((state) => state ?? "default"),
+ );
+
+ async setWidth(width: PopupWidthOption) {
+ await this.state.update(() => width);
+ }
+
+ /** Begin listening for state changes */
+ init() {
+ this.width$.subscribe((width: PopupWidthOption) => {
+ PopupWidthService.setStyle(width);
+ localStorage.setItem(PopupWidthService.LocalStorageKey, width);
+ });
+ }
+
+ private static setStyle(width: PopupWidthOption) {
+ const pxWidth = PopupWidthOptions[width] ?? PopupWidthOptions.default;
+ document.body.style.width = `${pxWidth}px`;
+ }
+
+ /**
+ * To keep the popup size from flickering on bootstrap, we store the width in `localStorage` so we can quickly & synchronously reference it.
+ **/
+ static initBodyWidthFromLocalStorage() {
+ const storedValue = localStorage.getItem(PopupWidthService.LocalStorageKey);
+ this.setStyle(storedValue as any);
+ }
+}
diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts
index c23da5ca7ce..31f610b7e74 100644
--- a/apps/browser/src/popup/app.component.ts
+++ b/apps/browser/src/popup/app.component.ts
@@ -25,6 +25,7 @@ import {
import { flagEnabled } from "../platform/flags";
import { PopupCompactModeService } from "../platform/popup/layout/popup-compact-mode.service";
+import { PopupWidthService } from "../platform/popup/layout/popup-width.service";
import { PopupViewCacheService } from "../platform/popup/view-cache/popup-view-cache.service";
import { initPopupClosedListener } from "../platform/services/popup-view-cache-background.service";
import { BrowserSendStateService } from "../tools/popup/services/browser-send-state.service";
@@ -44,6 +45,7 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn
export class AppComponent implements OnInit, OnDestroy {
private viewCacheService = inject(PopupViewCacheService);
private compactModeService = inject(PopupCompactModeService);
+ private widthService = inject(PopupWidthService);
private lastActivity: Date;
private activeUserId: UserId;
@@ -99,6 +101,7 @@ export class AppComponent implements OnInit, OnDestroy {
await this.viewCacheService.init();
this.compactModeService.init();
+ this.widthService.init();
// Component states must not persist between closing and reopening the popup, otherwise they become dead objects
// Clear them aggressively to make sure this doesn't occur
diff --git a/apps/browser/src/popup/main.ts b/apps/browser/src/popup/main.ts
index c98e30e6bca..db634ea2e2c 100644
--- a/apps/browser/src/popup/main.ts
+++ b/apps/browser/src/popup/main.ts
@@ -1,6 +1,7 @@
import { enableProdMode } from "@angular/core";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
+import { PopupWidthService } from "../platform/popup/layout/popup-width.service";
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
require("./scss/popup.scss");
@@ -8,7 +9,8 @@ require("./scss/tailwind.css");
import { AppModule } from "./app.module";
-// We put this first to minimize the delay in window changing.
+// We put these first to minimize the delay in window changing.
+PopupWidthService.initBodyWidthFromLocalStorage();
// Should be removed once we deprecate support for Safari 16.0 and older. See Jira ticket [PM-1861]
if (BrowserPlatformUtilsService.shouldApplySafariHeightFix(window)) {
document.documentElement.classList.add("safari_height_fix");
diff --git a/apps/browser/src/popup/scss/base.scss b/apps/browser/src/popup/scss/base.scss
index 89b8816567d..9412b911ee2 100644
--- a/apps/browser/src/popup/scss/base.scss
+++ b/apps/browser/src/popup/scss/base.scss
@@ -19,7 +19,7 @@ body {
}
body {
- width: 380px !important;
+ min-width: 380px !important;
height: 600px !important;
position: relative;
min-height: 100vh;
diff --git a/apps/browser/src/services/families-policy.service.spec.ts b/apps/browser/src/services/families-policy.service.spec.ts
new file mode 100644
index 00000000000..19291bcd825
--- /dev/null
+++ b/apps/browser/src/services/families-policy.service.spec.ts
@@ -0,0 +1,83 @@
+import { TestBed } from "@angular/core/testing";
+import { mock, MockProxy } from "jest-mock-extended";
+import { firstValueFrom, of } from "rxjs";
+
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
+
+import { FamiliesPolicyService } from "./families-policy.service"; // Adjust the import as necessary
+
+describe("FamiliesPolicyService", () => {
+ let service: FamiliesPolicyService;
+ let organizationService: MockProxy;
+ let policyService: MockProxy;
+
+ beforeEach(() => {
+ organizationService = mock();
+ policyService = mock();
+
+ TestBed.configureTestingModule({
+ providers: [
+ FamiliesPolicyService,
+ { provide: OrganizationService, useValue: organizationService },
+ { provide: PolicyService, useValue: policyService },
+ ],
+ });
+
+ service = TestBed.inject(FamiliesPolicyService);
+ });
+
+ it("should return false when there are no enterprise organizations", async () => {
+ jest.spyOn(service, "hasSingleEnterpriseOrg$").mockReturnValue(of(false));
+
+ const result = await firstValueFrom(service.isFreeFamilyPolicyEnabled$());
+ expect(result).toBe(false);
+ });
+
+ it("should return true when the policy is enabled for the one enterprise organization", async () => {
+ jest.spyOn(service, "hasSingleEnterpriseOrg$").mockReturnValue(of(true));
+
+ const organizations = [{ id: "org1", canManageSponsorships: true }] as Organization[];
+ organizationService.getAll$.mockReturnValue(of(organizations));
+
+ const policies = [{ organizationId: "org1", enabled: true }] as Policy[];
+ policyService.getAll$.mockReturnValue(of(policies));
+
+ const result = await firstValueFrom(service.isFreeFamilyPolicyEnabled$());
+ expect(result).toBe(true);
+ });
+
+ it("should return false when the policy is not enabled for the one enterprise organization", async () => {
+ jest.spyOn(service, "hasSingleEnterpriseOrg$").mockReturnValue(of(true));
+
+ const organizations = [{ id: "org1", canManageSponsorships: true }] as Organization[];
+ organizationService.getAll$.mockReturnValue(of(organizations));
+
+ const policies = [{ organizationId: "org1", enabled: false }] as Policy[];
+ policyService.getAll$.mockReturnValue(of(policies));
+
+ const result = await firstValueFrom(service.isFreeFamilyPolicyEnabled$());
+ expect(result).toBe(false);
+ });
+
+ it("should return true when there is exactly one enterprise organization that can manage sponsorships", async () => {
+ const organizations = [{ id: "org1", canManageSponsorships: true }] as Organization[];
+ organizationService.getAll$.mockReturnValue(of(organizations));
+
+ const result = await firstValueFrom(service.hasSingleEnterpriseOrg$());
+ expect(result).toBe(true);
+ });
+
+ it("should return false when there are multiple organizations that can manage sponsorships", async () => {
+ const organizations = [
+ { id: "org1", canManageSponsorships: true },
+ { id: "org2", canManageSponsorships: true },
+ ] as Organization[];
+ organizationService.getAll$.mockReturnValue(of(organizations));
+
+ const result = await firstValueFrom(service.hasSingleEnterpriseOrg$());
+ expect(result).toBe(false);
+ });
+});
diff --git a/apps/browser/src/services/families-policy.service.ts b/apps/browser/src/services/families-policy.service.ts
new file mode 100644
index 00000000000..426f39dcfd0
--- /dev/null
+++ b/apps/browser/src/services/families-policy.service.ts
@@ -0,0 +1,54 @@
+import { Injectable } from "@angular/core";
+import { map, Observable, of, switchMap } from "rxjs";
+
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
+import { PolicyType } from "@bitwarden/common/admin-console/enums";
+
+@Injectable({ providedIn: "root" })
+export class FamiliesPolicyService {
+ constructor(
+ private policyService: PolicyService,
+ private organizationService: OrganizationService,
+ ) {}
+
+ hasSingleEnterpriseOrg$(): Observable {
+ // Retrieve all organizations the user is part of
+ return this.organizationService.getAll$().pipe(
+ map((organizations) => {
+ // Filter to only those organizations that can manage sponsorships
+ const sponsorshipOrgs = organizations.filter((org) => org.canManageSponsorships);
+
+ // Check if there is exactly one organization that can manage sponsorships.
+ // This is important because users that are part of multiple organizations
+ // may always access free bitwarden family menu. We want to restrict access
+ // to the policy only when there is a single enterprise organization and the free family policy is turn.
+ return sponsorshipOrgs.length === 1;
+ }),
+ );
+ }
+
+ isFreeFamilyPolicyEnabled$(): Observable {
+ return this.hasSingleEnterpriseOrg$().pipe(
+ switchMap((hasSingleEnterpriseOrg) => {
+ if (!hasSingleEnterpriseOrg) {
+ return of(false);
+ }
+ return this.organizationService.getAll$().pipe(
+ map((organizations) => organizations.find((org) => org.canManageSponsorships)?.id),
+ switchMap((enterpriseOrgId) =>
+ this.policyService
+ .getAll$(PolicyType.FreeFamiliesSponsorshipPolicy)
+ .pipe(
+ map(
+ (policies) =>
+ policies.find((policy) => policy.organizationId === enterpriseOrgId)?.enabled ??
+ false,
+ ),
+ ),
+ ),
+ );
+ }),
+ );
+ }
+}
diff --git a/apps/browser/src/tools/popup/settings/about-page/more-from-bitwarden-page-v2.component.html b/apps/browser/src/tools/popup/settings/about-page/more-from-bitwarden-page-v2.component.html
index 9322ab5113e..a2d01ce752e 100644
--- a/apps/browser/src/tools/popup/settings/about-page/more-from-bitwarden-page-v2.component.html
+++ b/apps/browser/src/tools/popup/settings/about-page/more-from-bitwarden-page-v2.component.html
@@ -12,7 +12,12 @@
-
+