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 @@ - +