diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 21786339299..c14abd7cd86 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -119,9 +119,9 @@ jobs: run: cargo sort --workspace --check - name: Install cargo-deny - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@81ee1d48d9194cdcab880cbdc7d36e87d39874cb # v2.62.45 with: - tool: cargo-deny + tool: cargo-deny@0.18.5 - name: Run cargo deny working-directory: ./apps/desktop/desktop_native diff --git a/.github/workflows/sdk-breaking-change-check.yml b/.github/workflows/sdk-breaking-change-check.yml new file mode 100644 index 00000000000..49b91d2d1a1 --- /dev/null +++ b/.github/workflows/sdk-breaking-change-check.yml @@ -0,0 +1,167 @@ +# This workflow runs TypeScript compatibility checks when the SDK is updated. +# Triggered automatically by the SDK repository via repository_dispatch when SDK PRs are created/updated. +name: SDK Breaking Change Check +run-name: "SDK breaking change check (${{ github.event.client_payload.sdk_version }})" +on: + repository_dispatch: + types: [sdk-breaking-change-check] + +permissions: + contents: read + actions: read + id-token: write + +jobs: + type-check: + name: TypeScript compatibility check + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + _SOURCE_REPO: ${{ github.event.client_payload.source_repo }} + _SDK_VERSION: ${{ github.event.client_payload.sdk_version }} + _ARTIFACTS_RUN_ID: ${{ github.event.client_payload.artifacts_info.run_id }} + _ARTIFACT_NAME: ${{ github.event.client_payload.artifacts_info.artifact_name }} + _CLIENT_LABEL: ${{ github.event.client_payload.client_label }} + + steps: + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + + - name: Generate GH App token + uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 + id: app-token + with: + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Validate inputs + run: | + echo "šŸ” Validating required client_payload fields..." + + if [ -z "${_SOURCE_REPO}" ] || [ -z "${_SDK_VERSION}" ] || [ -z "${_ARTIFACTS_RUN_ID}" ] || [ -z "${_ARTIFACT_NAME}" ]; then + echo "::error::Missing required client_payload fields" + echo "SOURCE_REPO: ${_SOURCE_REPO}" + echo "SDK_VERSION: ${_SDK_VERSION}" + echo "ARTIFACTS_RUN_ID: ${_ARTIFACTS_RUN_ID}" + echo "ARTIFACT_NAME: ${_ARTIFACT_NAME}" + echo "CLIENT_LABEL: ${_CLIENT_LABEL}" + exit 1 + fi + + echo "āœ… All required payload fields are present" + - name: Check out clients repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Get Node Version + id: retrieve-node-version + run: | + NODE_NVMRC=$(cat .nvmrc) + NODE_VERSION=${NODE_NVMRC/v/''} + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + cache: 'npm' + cache-dependency-path: '**/package-lock.json' + node-version: ${{ steps.retrieve-node-version.outputs.node_version }} + + - name: Install Node dependencies + run: | + echo "šŸ“¦ Installing Node dependencies with retry logic..." + + RETRY_COUNT=0 + MAX_RETRIES=3 + while [ ${RETRY_COUNT} -lt ${MAX_RETRIES} ]; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo "šŸ”„ npm ci attempt ${RETRY_COUNT} of ${MAX_RETRIES}..." + + if npm ci; then + echo "āœ… npm ci successful" + break + else + echo "āŒ npm ci attempt ${RETRY_COUNT} failed" + [ ${RETRY_COUNT} -lt ${MAX_RETRIES} ] && sleep 5 + fi + done + + if [ ${RETRY_COUNT} -eq ${MAX_RETRIES} ]; then + echo "::error::npm ci failed after ${MAX_RETRIES} attempts" + exit 1 + fi + + - name: Download SDK artifacts + uses: bitwarden/gh-actions/download-artifacts@main + with: + github_token: ${{ steps.app-token.outputs.token }} + workflow: build-wasm-internal.yml + workflow_conclusion: success + run_id: ${{ env._ARTIFACTS_RUN_ID }} + artifacts: ${{ env._ARTIFACT_NAME }} + repo: ${{ env._SOURCE_REPO }} + path: ./sdk-internal + if_no_artifact_found: fail + + - name: Override SDK using npm link + working-directory: ./ + run: | + echo "šŸ”§ Setting up SDK override using npm link..." + echo "šŸ“Š SDK Version: ${_SDK_VERSION}" + echo "šŸ“¦ Artifact Source: ${_SOURCE_REPO} run ${_ARTIFACTS_RUN_ID}" + + echo "šŸ“‹ SDK package contents:" + ls -la ./sdk-internal/ + + echo "šŸ”— Creating npm link to SDK package..." + if ! npm link ./sdk-internal; then + echo "::error::Failed to link SDK package" + exit 1 + fi + + - name: Run TypeScript compatibility check + run: | + + echo "šŸ” Running TypeScript type checking for ${_CLIENT_LABEL} client with SDK version: ${_SDK_VERSION}" + echo "šŸŽÆ Type checking command: npm run test:types" + + # Add GitHub Step Summary output + { + echo "## šŸ“Š TypeScript Compatibility Check (${_CLIENT_LABEL})" + echo "- **Client**: ${_CLIENT_LABEL}" + echo "- **SDK Version**: ${_SDK_VERSION}" + echo "- **Source Repository**: ${_SOURCE_REPO}" + echo "- **Artifacts Run ID**: ${_ARTIFACTS_RUN_ID}" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + + TYPE_CHECK_START=$(date +%s) + + # Run type check with timeout - exit code determines gh run watch result + if timeout 10m npm run test:types; then + TYPE_CHECK_END=$(date +%s) + TYPE_CHECK_DURATION=$((TYPE_CHECK_END - TYPE_CHECK_START)) + echo "āœ… TypeScript compilation successful for ${_CLIENT_LABEL} client (${TYPE_CHECK_DURATION}s)" + echo "āœ… **Result**: TypeScript compilation successful" >> "$GITHUB_STEP_SUMMARY" + echo "No breaking changes detected in ${_CLIENT_LABEL} client for SDK version ${_SDK_VERSION}" >> "$GITHUB_STEP_SUMMARY" + else + TYPE_CHECK_END=$(date +%s) + TYPE_CHECK_DURATION=$((TYPE_CHECK_END - TYPE_CHECK_START)) + echo "āŒ TypeScript compilation failed for ${_CLIENT_LABEL} client after ${TYPE_CHECK_DURATION}s - breaking changes detected" + echo "āŒ **Result**: TypeScript compilation failed" >> "$GITHUB_STEP_SUMMARY" + echo "Breaking changes detected in ${_CLIENT_LABEL} client for SDK version ${_SDK_VERSION}" >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi \ No newline at end of file diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 00000000000..85b8b839182 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/apps/browser/package.json b/apps/browser/package.json index 744b53688b2..82d2ad7ab7a 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2025.10.1", + "version": "2025.11.0", "scripts": { "build": "npm run build:chrome", "build:bit": "npm run build:bit:chrome", diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 50c629e87f6..a8743b0db68 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1523,12 +1523,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -4980,6 +4974,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5772,6 +5776,30 @@ "atRiskLoginsSecured": { "message": "Great job securing your at-risk logins!" }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/autofill/content/components/buttons/action-button.ts b/apps/browser/src/autofill/content/components/buttons/action-button.ts index b43bed7f96b..73fc1e79ec5 100644 --- a/apps/browser/src/autofill/content/components/buttons/action-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/action-button.ts @@ -68,7 +68,7 @@ const actionButtonStyles = ({ overflow: hidden; text-align: center; text-overflow: ellipsis; - font-weight: 700; + font-weight: 500; ${disabled || isLoading ? ` diff --git a/apps/browser/src/autofill/content/components/constants/styles.ts b/apps/browser/src/autofill/content/components/constants/styles.ts index 55130781808..c1d6228459a 100644 --- a/apps/browser/src/autofill/content/components/constants/styles.ts +++ b/apps/browser/src/autofill/content/components/constants/styles.ts @@ -144,17 +144,17 @@ export const border = { export const typography = { body1: ` line-height: 24px; - font-family: Roboto, sans-serif; + font-family: Inter, sans-serif; font-size: 16px; `, body2: ` line-height: 20px; - font-family: Roboto, sans-serif; + font-family: Inter, sans-serif; font-size: 14px; `, helperMedium: ` line-height: 16px; - font-family: Roboto, sans-serif; + font-family: Inter, sans-serif; font-size: 12px; `, }; diff --git a/apps/browser/src/autofill/content/components/notification/at-risk-password/message.ts b/apps/browser/src/autofill/content/components/notification/at-risk-password/message.ts index 42d4907711d..9c55c1e7e2b 100644 --- a/apps/browser/src/autofill/content/components/notification/at-risk-password/message.ts +++ b/apps/browser/src/autofill/content/components/notification/at-risk-password/message.ts @@ -29,7 +29,7 @@ const baseTextStyles = css` text-align: left; text-overflow: ellipsis; line-height: 24px; - font-family: Roboto, sans-serif; + font-family: Inter, sans-serif; font-size: 16px; `; diff --git a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts index 7f15d882297..36ea9c1f9d6 100644 --- a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts +++ b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts @@ -84,7 +84,7 @@ const baseTextStyles = css` text-align: left; text-overflow: ellipsis; line-height: 24px; - font-family: Roboto, sans-serif; + font-family: Inter, sans-serif; font-size: 16px; `; @@ -115,7 +115,7 @@ const notificationConfirmationButtonTextStyles = (theme: Theme) => css` ${baseTextStyles} color: ${themes[theme].primary[600]}; - font-weight: 700; + font-weight: 500; cursor: pointer; `; diff --git a/apps/browser/src/autofill/content/components/notification/header-message.ts b/apps/browser/src/autofill/content/components/notification/header-message.ts index 47fe8cd2828..2e51d82dd07 100644 --- a/apps/browser/src/autofill/content/components/notification/header-message.ts +++ b/apps/browser/src/autofill/content/components/notification/header-message.ts @@ -19,7 +19,7 @@ const notificationHeaderMessageStyles = (theme: Theme) => css` line-height: 28px; white-space: nowrap; color: ${themes[theme].text.main}; - font-family: Roboto, sans-serif; + font-family: Inter, sans-serif; font-size: 18px; - font-weight: 600; + font-weight: 500; `; diff --git a/apps/browser/src/autofill/content/components/option-selection/option-items.ts b/apps/browser/src/autofill/content/components/option-selection/option-items.ts index ceb72905357..58216b6c1b2 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-items.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-items.ts @@ -94,7 +94,7 @@ const optionsLabelStyles = ({ theme }: { theme: Theme }) => css` user-select: none; padding: 0.375rem ${spacing["3"]}; color: ${themes[theme].text.muted}; - font-weight: 600; + font-weight: 500; `; export const optionsMenuItemMaxWidth = 260; diff --git a/apps/browser/src/autofill/content/components/rows/action-row.ts b/apps/browser/src/autofill/content/components/rows/action-row.ts index 0380f91012a..8f13b166156 100644 --- a/apps/browser/src/autofill/content/components/rows/action-row.ts +++ b/apps/browser/src/autofill/content/components/rows/action-row.ts @@ -34,7 +34,7 @@ const actionRowStyles = (theme: Theme) => css` min-height: 40px; text-align: left; color: ${themes[theme].primary["600"]}; - font-weight: 700; + font-weight: 500; > span { display: block; diff --git a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts index 8de48a49a8e..5e523a1a48d 100644 --- a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts @@ -6,7 +6,7 @@ import { filter, firstValueFrom, fromEvent, - fromEventPattern, + map, merge, Observable, Subject, @@ -28,6 +28,7 @@ import { import { Utils } from "@bitwarden/common/platform/misc/utils"; import { BrowserApi } from "../../../platform/browser/browser-api"; +import { fromChromeEvent } from "../../../platform/browser/from-chrome-event"; // FIXME (PM-22628): Popup imports are forbidden in background // eslint-disable-next-line no-restricted-imports import { closeFido2Popout, openFido2Popout } from "../../../vault/popup/utils/vault-popout-window"; @@ -232,12 +233,8 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi } }); - this.windowClosed$ = fromEventPattern( - // FIXME: Make sure that is does not cause a memory leak in Safari or use BrowserApi.AddListener - // and test that it doesn't break. Tracking Ticket: https://bitwarden.atlassian.net/browse/PM-4735 - // eslint-disable-next-line no-restricted-syntax - (handler: any) => chrome.windows.onRemoved.addListener(handler), - (handler: any) => chrome.windows.onRemoved.removeListener(handler), + this.windowClosed$ = fromChromeEvent(chrome.windows.onRemoved).pipe( + map(([windowId]) => windowId), ); BrowserFido2UserInterfaceSession.sendMessage({ diff --git a/apps/browser/src/autofill/notification/bar.html b/apps/browser/src/autofill/notification/bar.html index c0b57de612e..8934fe6a031 100644 --- a/apps/browser/src/autofill/notification/bar.html +++ b/apps/browser/src/autofill/notification/bar.html @@ -1,5 +1,4 @@ - - + Bitwarden diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss b/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss index 93f5f647ffe..ee9c68ee603 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss @@ -82,7 +82,7 @@ body * { width: 100%; font-family: $font-family-sans-serif; font-size: 1.6rem; - font-weight: 700; + font-weight: 500; text-align: left; background: transparent; border: none; @@ -187,7 +187,7 @@ body * { top: 0; z-index: 1; font-family: $font-family-sans-serif; - font-weight: 600; + font-weight: 500; font-size: 1rem; line-height: 1.3; letter-spacing: 0.025rem; diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts index 96b05b81c96..3c19589afef 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts @@ -32,6 +32,17 @@ import { InlineMenuFieldQualificationService } from "./inline-menu-field-qualifi const defaultWindowReadyState = document.readyState; const defaultDocumentVisibilityState = document.visibilityState; + +const mockRect = (rect: { left: number; top: number; width: number; height: number }) => + ({ + ...rect, + x: rect.left, + y: rect.top, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + toJSON: () => ({}), + }) as DOMRectReadOnly; + describe("AutofillOverlayContentService", () => { let domQueryService: DomQueryService; let domElementVisibilityService: DomElementVisibilityService; @@ -2154,6 +2165,10 @@ describe("AutofillOverlayContentService", () => { }); it("calculates the sub frame's offsets if a single frame with the referenced url exists", async () => { + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + jest + .spyOn(iframe, "getBoundingClientRect") + .mockReturnValue(mockRect({ left: 0, top: 0, width: 1, height: 1 })); sendMockExtensionMessage( { command: "getSubFrameOffsets", @@ -2270,6 +2285,9 @@ describe("AutofillOverlayContentService", () => { }); document.body.innerHTML = ``; const iframe = document.querySelector("iframe") as HTMLIFrameElement; + jest + .spyOn(iframe, "getBoundingClientRect") + .mockReturnValue(mockRect({ width: 1, height: 1, left: 2, top: 2 })); const subFrameData = { url: "https://example.com/", frameId: 10, @@ -2305,6 +2323,9 @@ describe("AutofillOverlayContentService", () => { it("posts the calculated sub frame data to the background", async () => { document.body.innerHTML = ``; const iframe = document.querySelector("iframe") as HTMLIFrameElement; + jest + .spyOn(iframe, "getBoundingClientRect") + .mockReturnValue(mockRect({ width: 1, height: 1, left: 2, top: 2 })); const subFrameData = { url: "https://example.com/", frameId: 10, @@ -2335,6 +2356,39 @@ describe("AutofillOverlayContentService", () => { }); }); + describe("calculateSubFrameOffsets", () => { + it("returns null when iframe has zero width and height", () => { + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + + jest + .spyOn(iframe, "getBoundingClientRect") + .mockReturnValue(mockRect({ left: 0, top: 0, width: 0, height: 0 })); + + const result = autofillOverlayContentService["calculateSubFrameOffsets"]( + iframe, + "https://example.com/", + 10, + ); + + expect(result).toBeNull(); + }); + + it("returns null when iframe is not connected to the document", () => { + const iframe = document.createElement("iframe") as HTMLIFrameElement; + + jest + .spyOn(iframe, "getBoundingClientRect") + .mockReturnValue(mockRect({ width: 100, height: 50, left: 10, top: 20 })); + + const result = autofillOverlayContentService["calculateSubFrameOffsets"]( + iframe, + "https://example.com/", + 10, + ); + expect(result).toBeNull(); + }); + }); + describe("checkMostRecentlyFocusedFieldHasValue message handler", () => { it("returns true if the most recently focused field has a truthy value", async () => { autofillOverlayContentService["mostRecentlyFocusedField"] = mock< diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 656516d1119..7c98859070a 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -1485,12 +1485,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ frameId?: number, ): SubFrameOffsetData { const iframeRect = iframeElement.getBoundingClientRect(); + const iframeRectHasSize = iframeRect.width > 0 && iframeRect.height > 0; const iframeStyles = globalThis.getComputedStyle(iframeElement); const paddingLeft = parseInt(iframeStyles.getPropertyValue("padding-left")) || 0; const paddingTop = parseInt(iframeStyles.getPropertyValue("padding-top")) || 0; const borderWidthLeft = parseInt(iframeStyles.getPropertyValue("border-left-width")) || 0; const borderWidthTop = parseInt(iframeStyles.getPropertyValue("border-top-width")) || 0; + if (!iframeRect || !iframeRectHasSize || !iframeElement.isConnected) { + return null; + } + return { url: subFrameUrl, frameId, @@ -1525,6 +1530,10 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ subFrameData.frameId, ); + if (!subFrameOffsets) { + return; + } + subFrameData.top += subFrameOffsets.top; subFrameData.left += subFrameOffsets.left; @@ -1657,10 +1666,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ globalThis.addEventListener(EVENTS.RESIZE, repositionHandler); } - private shouldRepositionSubFrameInlineMenuOnScroll = async () => { - return await this.sendExtensionMessage("shouldRepositionSubFrameInlineMenuOnScroll"); - }; - /** * Removes the listeners that facilitate repositioning * the overlay elements on scroll or resize. diff --git a/apps/browser/src/autofill/shared/styles/variables.scss b/apps/browser/src/autofill/shared/styles/variables.scss index 1e804ed8fd2..f356eb86f3a 100644 --- a/apps/browser/src/autofill/shared/styles/variables.scss +++ b/apps/browser/src/autofill/shared/styles/variables.scss @@ -1,6 +1,6 @@ $dark-icon-themes: "theme_dark"; -$font-family-sans-serif: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif; +$font-family-sans-serif: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; $font-family-source-code-pro: "Source Code Pro", monospace; $font-size-base: 14px; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 926be3c4170..07deb06bc7a 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -293,6 +293,7 @@ import { AutofillBadgeUpdaterService } from "../autofill/services/autofill-badge import AutofillService from "../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service"; import { SafariApp } from "../browser/safariApp"; +import { PhishingDataService } from "../dirt/phishing-detection/services/phishing-data.service"; import { PhishingDetectionService } from "../dirt/phishing-detection/services/phishing-detection.service"; import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service"; import VaultTimeoutService from "../key-management/vault-timeout/vault-timeout.service"; @@ -491,6 +492,9 @@ export default class MainBackground { private popupViewCacheBackgroundService: PopupViewCacheBackgroundService; private popupRouterCacheBackgroundService: PopupRouterCacheBackgroundService; + // DIRT + private phishingDataService: PhishingDataService; + constructor() { // Services const lockedCallback = async (userId: UserId) => { @@ -1451,15 +1455,20 @@ export default class MainBackground { this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + this.phishingDataService = new PhishingDataService( + this.apiService, + this.taskSchedulerService, + this.globalStateProvider, + this.logService, + this.platformUtilsService, + ); + PhishingDetectionService.initialize( this.accountService, - this.auditService, this.billingAccountProfileStateService, this.configService, - this.eventCollectionService, this.logService, - this.storageService, - this.taskSchedulerService, + this.phishingDataService, ); this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts new file mode 100644 index 00000000000..94f3e99f8be --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts @@ -0,0 +1,158 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + DefaultTaskSchedulerService, + TaskSchedulerService, +} from "@bitwarden/common/platform/scheduling"; +import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; +import { LogService } from "@bitwarden/logging"; + +import { PhishingDataService, PhishingData, PHISHING_DOMAINS_KEY } from "./phishing-data.service"; + +describe("PhishingDataService", () => { + let service: PhishingDataService; + let apiService: MockProxy; + let taskSchedulerService: TaskSchedulerService; + let logService: MockProxy; + let platformUtilsService: MockProxy; + const stateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider(); + + const setMockState = (state: PhishingData) => { + stateProvider.getFake(PHISHING_DOMAINS_KEY).stateSubject.next(state); + return state; + }; + + let fetchChecksumSpy: jest.SpyInstance; + let fetchDomainsSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + apiService = mock(); + logService = mock(); + + platformUtilsService = mock(); + platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); + + taskSchedulerService = new DefaultTaskSchedulerService(logService); + + service = new PhishingDataService( + apiService, + taskSchedulerService, + stateProvider, + logService, + platformUtilsService, + ); + + fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingDomainsChecksum"); + fetchDomainsSpy = jest.spyOn(service as any, "fetchPhishingDomains"); + }); + + describe("isPhishingDomains", () => { + it("should detect a phishing domain", async () => { + setMockState({ + domains: ["phish.com", "badguy.net"], + timestamp: Date.now(), + checksum: "abc123", + applicationVersion: "1.0.0", + }); + const url = new URL("http://phish.com"); + const result = await service.isPhishingDomain(url); + expect(result).toBe(true); + }); + + it("should not detect a safe domain", async () => { + setMockState({ + domains: ["phish.com", "badguy.net"], + timestamp: Date.now(), + checksum: "abc123", + applicationVersion: "1.0.0", + }); + const url = new URL("http://safe.com"); + const result = await service.isPhishingDomain(url); + expect(result).toBe(false); + }); + + it("should match against root domain", async () => { + setMockState({ + domains: ["phish.com", "badguy.net"], + timestamp: Date.now(), + checksum: "abc123", + applicationVersion: "1.0.0", + }); + const url = new URL("http://phish.com/about"); + const result = await service.isPhishingDomain(url); + expect(result).toBe(true); + }); + + it("should not error on empty state", async () => { + setMockState(undefined as any); + const url = new URL("http://phish.com/about"); + const result = await service.isPhishingDomain(url); + expect(result).toBe(false); + }); + }); + + describe("getNextDomains", () => { + it("refetches all domains if applicationVersion has changed", async () => { + const prev: PhishingData = { + domains: ["a.com"], + timestamp: Date.now() - 60000, + checksum: "old", + applicationVersion: "1.0.0", + }; + fetchChecksumSpy.mockResolvedValue("new"); + fetchDomainsSpy.mockResolvedValue(["d.com", "e.com"]); + platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0"); + + const result = await service.getNextDomains(prev); + + expect(result!.domains).toEqual(["d.com", "e.com"]); + expect(result!.checksum).toBe("new"); + expect(result!.applicationVersion).toBe("2.0.0"); + }); + + it("only updates timestamp if checksum matches", async () => { + const prev: PhishingData = { + domains: ["a.com"], + timestamp: Date.now() - 60000, + checksum: "abc", + applicationVersion: "1.0.0", + }; + fetchChecksumSpy.mockResolvedValue("abc"); + const result = await service.getNextDomains(prev); + expect(result!.domains).toEqual(prev.domains); + expect(result!.checksum).toBe("abc"); + expect(result!.timestamp).not.toBe(prev.timestamp); + }); + + it("patches daily domains if cache is fresh", async () => { + const prev: PhishingData = { + domains: ["a.com"], + timestamp: Date.now() - 60000, + checksum: "old", + applicationVersion: "1.0.0", + }; + fetchChecksumSpy.mockResolvedValue("new"); + fetchDomainsSpy.mockResolvedValue(["b.com", "c.com"]); + const result = await service.getNextDomains(prev); + expect(result!.domains).toEqual(["a.com", "b.com", "c.com"]); + expect(result!.checksum).toBe("new"); + }); + + it("fetches all domains if cache is old", async () => { + const prev: PhishingData = { + domains: ["a.com"], + timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000, + checksum: "old", + applicationVersion: "1.0.0", + }; + fetchChecksumSpy.mockResolvedValue("new"); + fetchDomainsSpy.mockResolvedValue(["d.com", "e.com"]); + const result = await service.getNextDomains(prev); + expect(result!.domains).toEqual(["d.com", "e.com"]); + expect(result!.checksum).toBe("new"); + }); + }); +}); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts new file mode 100644 index 00000000000..0c5ba500efc --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -0,0 +1,221 @@ +import { + catchError, + EMPTY, + first, + firstValueFrom, + map, + retry, + startWith, + Subject, + switchMap, + tap, + timer, +} from "rxjs"; + +import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ScheduledTaskNames, TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; +import { LogService } from "@bitwarden/logging"; +import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bitwarden/state"; + +export type PhishingData = { + domains: string[]; + timestamp: number; + checksum: string; + + /** + * We store the application version to refetch the entire dataset on a new client release. + * This counteracts daily appends updates not removing inactive or false positive domains. + */ + applicationVersion: string; +}; + +export const PHISHING_DOMAINS_KEY = new KeyDefinition( + PHISHING_DETECTION_DISK, + "phishingDomains", + { + deserializer: (value: PhishingData) => + value ?? { domains: [], timestamp: 0, checksum: "", applicationVersion: "" }, + }, +); + +/** Coordinates fetching, caching, and patching of known phishing domains */ +export class PhishingDataService { + private static readonly RemotePhishingDatabaseUrl = + "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-domains-ACTIVE.txt"; + private static readonly RemotePhishingDatabaseChecksumUrl = + "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.md5"; + private static readonly RemotePhishingDatabaseTodayUrl = + "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-domains-NEW-today.txt"; + + private _testDomains = this.getTestDomains(); + private _cachedState = this.globalStateProvider.get(PHISHING_DOMAINS_KEY); + private _domains$ = this._cachedState.state$.pipe( + map( + (state) => + new Set( + (state?.domains?.filter((line) => line.trim().length > 0) ?? []).concat( + this._testDomains, + ), + ), + ), + ); + + // How often are new domains added to the remote? + readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours + + private _triggerUpdate$ = new Subject(); + update$ = this._triggerUpdate$.pipe( + startWith(), // Always emit once + tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)), + switchMap(() => + this._cachedState.state$.pipe( + first(), // Only take the first value to avoid an infinite loop when updating the cache below + switchMap(async (cachedState) => { + const next = await this.getNextDomains(cachedState); + if (next) { + await this._cachedState.update(() => next); + this.logService.info(`[PhishingDataService] cache updated`); + } + }), + retry({ + count: 3, + delay: (err, count) => { + this.logService.error( + `[PhishingDataService] Unable to update domains. Attempt ${count}.`, + err, + ); + return timer(5 * 60 * 1000); // 5 minutes + }, + resetOnSuccess: true, + }), + catchError( + ( + err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */, + ) => { + this.logService.error( + "[PhishingDataService] Retries unsuccessful. Unable to update domains.", + err, + ); + return EMPTY; + }, + ), + ), + ), + ); + + constructor( + private apiService: ApiService, + private taskSchedulerService: TaskSchedulerService, + private globalStateProvider: GlobalStateProvider, + private logService: LogService, + private platformUtilsService: PlatformUtilsService, + ) { + this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => { + this._triggerUpdate$.next(); + }); + this.taskSchedulerService.setInterval( + ScheduledTaskNames.phishingDomainUpdate, + this.UPDATE_INTERVAL_DURATION, + ); + } + + /** + * Checks if the given URL is a known phishing domain + * + * @param url The URL to check + * @returns True if the URL is a known phishing domain, false otherwise + */ + async isPhishingDomain(url: URL): Promise { + const domains = await firstValueFrom(this._domains$); + const result = domains.has(url.hostname); + if (result) { + this.logService.debug("[PhishingDataService] Caught phishing domain:", url.hostname); + return true; + } + return false; + } + + async getNextDomains(prev: PhishingData | null): Promise { + prev = prev ?? { domains: [], timestamp: 0, checksum: "", applicationVersion: "" }; + const timestamp = Date.now(); + const prevAge = timestamp - prev.timestamp; + this.logService.info(`[PhishingDataService] Cache age: ${prevAge}`); + + const applicationVersion = await this.platformUtilsService.getApplicationVersion(); + + // If checksum matches, return existing data with new timestamp & version + const remoteChecksum = await this.fetchPhishingDomainsChecksum(); + if (remoteChecksum && prev.checksum === remoteChecksum) { + this.logService.info( + `[PhishingDataService] Remote checksum matches local checksum, updating timestamp only.`, + ); + return { ...prev, timestamp, applicationVersion }; + } + // Checksum is different, data needs to be updated. + + // Approach 1: Fetch only new domains and append + const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION; + if (isOneDayOldMax && applicationVersion === prev.applicationVersion) { + const dailyDomains: string[] = await this.fetchPhishingDomains( + PhishingDataService.RemotePhishingDatabaseTodayUrl, + ); + this.logService.info( + `[PhishingDataService] ${dailyDomains.length} new phishing domains added`, + ); + return { + domains: prev.domains.concat(dailyDomains), + checksum: remoteChecksum, + timestamp, + applicationVersion, + }; + } + + // Approach 2: Fetch all domains + const domains = await this.fetchPhishingDomains(PhishingDataService.RemotePhishingDatabaseUrl); + return { + domains, + timestamp, + checksum: remoteChecksum, + applicationVersion, + }; + } + + private async fetchPhishingDomainsChecksum() { + const response = await this.apiService.nativeFetch( + new Request(PhishingDataService.RemotePhishingDatabaseChecksumUrl), + ); + if (!response.ok) { + throw new Error(`[PhishingDataService] Failed to fetch checksum: ${response.status}`); + } + return response.text(); + } + + private async fetchPhishingDomains(url: string) { + const response = await this.apiService.nativeFetch(new Request(url)); + + if (!response.ok) { + throw new Error(`[PhishingDataService] Failed to fetch domains: ${response.status}`); + } + + return response.text().then((text) => text.split("\n")); + } + + private getTestDomains() { + const flag = devFlagEnabled("testPhishingUrls"); + if (!flag) { + return []; + } + + const domains = devFlagValue("testPhishingUrls") as unknown[]; + if (domains && domains instanceof Array) { + this.logService.debug( + "[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing domains:", + domains, + ); + return domains as string[]; + } + return []; + } +} diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts index d6aca6abea0..5d2c4847671 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts @@ -1,48 +1,36 @@ import { of } from "rxjs"; -import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; -import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling/task-scheduler.service"; +import { PhishingDataService } from "./phishing-data.service"; import { PhishingDetectionService } from "./phishing-detection.service"; describe("PhishingDetectionService", () => { let accountService: AccountService; - let auditService: AuditService; let billingAccountProfileStateService: BillingAccountProfileStateService; let configService: ConfigService; - let eventCollectionService: EventCollectionService; let logService: LogService; - let storageService: AbstractStorageService; - let taskSchedulerService: TaskSchedulerService; + let phishingDataService: PhishingDataService; beforeEach(() => { accountService = { getAccount$: jest.fn(() => of(null)) } as any; - auditService = { getKnownPhishingDomains: jest.fn() } as any; billingAccountProfileStateService = {} as any; configService = { getFeatureFlag$: jest.fn(() => of(false)) } as any; - eventCollectionService = {} as any; logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any; - storageService = { get: jest.fn(), save: jest.fn() } as any; - taskSchedulerService = { registerTaskHandler: jest.fn(), setInterval: jest.fn() } as any; + phishingDataService = {} as any; }); it("should initialize without errors", () => { expect(() => { PhishingDetectionService.initialize( accountService, - auditService, billingAccountProfileStateService, configService, - eventCollectionService, logService, - storageService, - taskSchedulerService, + phishingDataService, ); }).not.toThrow(); }); @@ -66,13 +54,10 @@ describe("PhishingDetectionService", () => { // Run the initialization PhishingDetectionService.initialize( accountService, - auditService, billingAccountProfileStateService, configService, - eventCollectionService, logService, - storageService, - taskSchedulerService, + phishingDataService, ); }); @@ -105,23 +90,10 @@ describe("PhishingDetectionService", () => { // Run the initialization PhishingDetectionService.initialize( accountService, - auditService, billingAccountProfileStateService, configService, - eventCollectionService, logService, - storageService, - taskSchedulerService, + phishingDataService, ); }); - - it("should detect phishing domains", () => { - PhishingDetectionService["_knownPhishingDomains"].add("phishing.com"); - const url = new URL("https://phishing.com"); - expect(PhishingDetectionService.isPhishingDomain(url)).toBe(true); - const safeUrl = new URL("https://safe.com"); - expect(PhishingDetectionService.isPhishingDomain(safeUrl)).toBe(false); - }); - - // Add more tests for other methods as needed }); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index 179431b155c..8232b053526 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -1,28 +1,14 @@ -import { - combineLatest, - concatMap, - delay, - EMPTY, - map, - Subject, - Subscription, - switchMap, -} from "rxjs"; +import { combineLatest, concatMap, delay, EMPTY, map, Subject, switchMap, takeUntil } from "rxjs"; -import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; -import { devFlagEnabled, devFlagValue } from "@bitwarden/common/platform/misc/flags"; -import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; -import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling/task-scheduler.service"; import { BrowserApi } from "../../../platform/browser/browser-api"; +import { PhishingDataService } from "./phishing-data.service"; import { CaughtPhishingDomain, isPhishingDetectionMessage, @@ -32,39 +18,23 @@ import { } from "./phishing-detection.types"; export class PhishingDetectionService { - private static readonly _UPDATE_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds - private static readonly _RETRY_INTERVAL = 5 * 60 * 1000; // 5 minutes - private static readonly _MAX_RETRIES = 3; - private static readonly _STORAGE_KEY = "phishing_domains_cache"; - private static _auditService: AuditService; + private static _destroy$ = new Subject(); + private static _logService: LogService; - private static _storageService: AbstractStorageService; - private static _taskSchedulerService: TaskSchedulerService; - private static _updateCacheSubscription: Subscription | null = null; - private static _retrySubscription: Subscription | null = null; + private static _phishingDataService: PhishingDataService; + private static _navigationEventsSubject = new Subject(); - private static _navigationEvents: Subscription | null = null; - private static _knownPhishingDomains = new Set(); private static _caughtTabs: Map = new Map(); - private static _isInitialized = false; - private static _isUpdating = false; - private static _retryCount = 0; - private static _lastUpdateTime: number = 0; static initialize( accountService: AccountService, - auditService: AuditService, billingAccountProfileStateService: BillingAccountProfileStateService, configService: ConfigService, - eventCollectionService: EventCollectionService, logService: LogService, - storageService: AbstractStorageService, - taskSchedulerService: TaskSchedulerService, + phishingDataService: PhishingDataService, ): void { - this._auditService = auditService; this._logService = logService; - this._storageService = storageService; - this._taskSchedulerService = taskSchedulerService; + this._phishingDataService = phishingDataService; logService.info("[PhishingDetectionService] Initialize called. Checking prerequisites..."); @@ -98,21 +68,6 @@ export class PhishingDetectionService { .subscribe(); } - /** - * Checks if the given URL is a known phishing domain - * - * @param url The URL to check - * @returns True if the URL is a known phishing domain, false otherwise - */ - static isPhishingDomain(url: URL): boolean { - const result = this._knownPhishingDomains.has(url.hostname); - if (result) { - this._logService.debug("[PhishingDetectionService] Caught phishing domain:", url.hostname); - return true; - } - return false; - } - /** * Sends a message to the phishing detection service to close the warning page */ @@ -146,45 +101,12 @@ export class PhishingDetectionService { } } - /** - * Initializes the phishing detection service, setting up listeners and registering tasks - */ - private static async _setup(): Promise { - if (this._isInitialized) { - this._logService.info("[PhishingDetectionService] Already initialized, skipping setup."); - return; - } - - this._isInitialized = true; - this._setupListeners(); - - // Register the update task - this._taskSchedulerService.registerTaskHandler( - ScheduledTaskNames.phishingDomainUpdate, - async () => { - try { - await this._fetchKnownPhishingDomains(); - } catch (error) { - this._logService.error( - "[PhishingDetectionService] Failed to update phishing domains in task handler:", - error, - ); - } - }, - ); - - // Initial load of cached domains - await this._loadCachedDomains(); - - // Set up periodic updates every 24 hours - this._setupPeriodicUpdates(); - this._logService.debug("[PhishingDetectionService] Phishing detection feature is initialized."); - } - /** * Sets up listeners for messages from the web page and web navigation events */ - private static _setupListeners(): void { + private static _setup(): void { + this._phishingDataService.update$.pipe(takeUntil(this._destroy$)).subscribe(); + // Setup listeners from web page/content script BrowserApi.addListener(chrome.runtime.onMessage, this._handleExtensionMessage.bind(this)); BrowserApi.addListener(chrome.tabs.onReplaced, this._handleReplacementEvent.bind(this)); @@ -192,9 +114,10 @@ export class PhishingDetectionService { // When a navigation event occurs, check if a replace event for the same tabId exists, // and call the replace handler before handling navigation. - this._navigationEvents = this._navigationEventsSubject + this._navigationEventsSubject .pipe( delay(100), // Delay slightly to allow replace events to be caught + takeUntil(this._destroy$), ) .subscribe(({ tabId, changeInfo, tab }) => { void this._processNavigation(tabId, changeInfo, tab); @@ -271,7 +194,7 @@ export class PhishingDetectionService { } // Check if tab is navigating to a phishing url and handle navigation - this._checkTabForPhishing(tabId, new URL(tab.url)); + await this._checkTabForPhishing(tabId, new URL(tab.url)); await this._handleTabNavigation(tabId); } @@ -371,11 +294,11 @@ export class PhishingDetectionService { * @param tabId Tab to check for phishing domain * @param url URL of the tab to check */ - private static _checkTabForPhishing(tabId: PhishingDetectionTabId, url: URL) { + private static async _checkTabForPhishing(tabId: PhishingDetectionTabId, url: URL) { // Check if the tab already being tracked const caughtTab = this._caughtTabs.get(tabId); - const isPhishing = this.isPhishingDomain(url); + const isPhishing = await this._phishingDataService.isPhishingDomain(url); this._logService.debug( `[PhishingDetectionService] Checking for phishing url. Result: ${isPhishing} on ${url}`, ); @@ -458,237 +381,16 @@ export class PhishingDetectionService { } } - /** - * Sets up periodic updates for phishing domains - */ - private static _setupPeriodicUpdates() { - // Clean up any existing subscriptions - if (this._updateCacheSubscription) { - this._updateCacheSubscription.unsubscribe(); - } - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - } - - this._updateCacheSubscription = this._taskSchedulerService.setInterval( - ScheduledTaskNames.phishingDomainUpdate, - this._UPDATE_INTERVAL, - ); - } - - /** - * Schedules a retry for updating phishing domains if the update fails - */ - private static _scheduleRetry() { - // If we've exceeded max retries, stop retrying - if (this._retryCount >= this._MAX_RETRIES) { - this._logService.warning( - `[PhishingDetectionService] Max retries (${this._MAX_RETRIES}) reached for phishing domain update. Will try again in ${this._UPDATE_INTERVAL / (1000 * 60 * 60)} hours.`, - ); - this._retryCount = 0; - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - this._retrySubscription = null; - } - return; - } - - // Clean up existing retry subscription if any - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - } - - // Increment retry count - this._retryCount++; - - // Schedule a retry in 5 minutes - this._retrySubscription = this._taskSchedulerService.setInterval( - ScheduledTaskNames.phishingDomainUpdate, - this._RETRY_INTERVAL, - ); - - this._logService.info( - `[PhishingDetectionService] Scheduled retry ${this._retryCount}/${this._MAX_RETRIES} for phishing domain update in ${this._RETRY_INTERVAL / (1000 * 60)} minutes`, - ); - } - - /** - * Handles adding test phishing URLs from dev flags for testing purposes - */ - private static _handleTestUrls() { - if (devFlagEnabled("testPhishingUrls")) { - const testPhishingUrls = devFlagValue("testPhishingUrls"); - this._logService.debug( - "[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing domains:", - testPhishingUrls, - ); - if (testPhishingUrls && testPhishingUrls instanceof Array) { - testPhishingUrls.forEach((domain) => { - if (domain && typeof domain === "string") { - this._knownPhishingDomains.add(domain); - } - }); - } - } - } - - /** - * Loads cached phishing domains from storage - * If no cache exists or it is expired, fetches the latest domains - */ - private static async _loadCachedDomains() { - try { - const cachedData = await this._storageService.get<{ domains: string[]; timestamp: number }>( - this._STORAGE_KEY, - ); - if (cachedData) { - this._logService.info("[PhishingDetectionService] Phishing cachedData exists"); - const phishingDomains = cachedData.domains || []; - - this._setKnownPhishingDomains(phishingDomains); - this._handleTestUrls(); - } - - // If cache is empty or expired, trigger an immediate update - if ( - this._knownPhishingDomains.size === 0 || - Date.now() - this._lastUpdateTime >= this._UPDATE_INTERVAL - ) { - await this._fetchKnownPhishingDomains(); - } - } catch (error) { - this._logService.error( - "[PhishingDetectionService] Failed to load cached phishing domains:", - error, - ); - this._handleTestUrls(); - } - } - - /** - * Fetches the latest known phishing domains from the audit service - * Updates the cache and handles retries if necessary - */ - static async _fetchKnownPhishingDomains(): Promise { - let domains: string[] = []; - - // Prevent concurrent updates - if (this._isUpdating) { - this._logService.warning( - "[PhishingDetectionService] Update already in progress, skipping...", - ); - return; - } - - try { - this._logService.info("[PhishingDetectionService] Starting phishing domains update..."); - this._isUpdating = true; - domains = await this._auditService.getKnownPhishingDomains(); - this._setKnownPhishingDomains(domains); - - await this._saveDomains(); - - this._resetRetry(); - this._isUpdating = false; - - this._logService.info("[PhishingDetectionService] Successfully fetched domains"); - } catch (error) { - this._logService.error( - "[PhishingDetectionService] Failed to fetch known phishing domains.", - error, - ); - - this._scheduleRetry(); - this._isUpdating = false; - - throw error; - } - } - - /** - * Saves the known phishing domains to storage - * Caches the updated domains and updates the last update time - */ - private static async _saveDomains() { - try { - // Cache the updated domains - await this._storageService.save(this._STORAGE_KEY, { - domains: Array.from(this._knownPhishingDomains), - timestamp: this._lastUpdateTime, - }); - this._logService.info( - `[PhishingDetectionService] Updated phishing domains cache with ${this._knownPhishingDomains.size} domains`, - ); - } catch (error) { - this._logService.error( - "[PhishingDetectionService] Failed to save known phishing domains.", - error, - ); - this._scheduleRetry(); - throw error; - } - } - - /** - * Resets the retry count and clears the retry subscription - */ - private static _resetRetry(): void { - this._logService.info( - `[PhishingDetectionService] Resetting retry count and clearing retry subscription.`, - ); - // Reset retry count and clear retry subscription on success - this._retryCount = 0; - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - this._retrySubscription = null; - } - } - - /** - * Adds phishing domains to the known phishing domains set - * Clears old domains to prevent memory leaks - * - * @param domains Array of phishing domains to add - */ - private static _setKnownPhishingDomains(domains: string[]): void { - this._logService.debug( - `[PhishingDetectionService] Tracking ${domains.length} phishing domains`, - ); - - // Clear old domains to prevent memory leaks - this._knownPhishingDomains.clear(); - - domains.forEach((domain: string) => { - if (domain) { - this._knownPhishingDomains.add(domain); - } - }); - this._lastUpdateTime = Date.now(); - } - /** * Cleans up the phishing detection service * Unsubscribes from all subscriptions and clears caches */ private static _cleanup() { - if (this._updateCacheSubscription) { - this._updateCacheSubscription.unsubscribe(); - this._updateCacheSubscription = null; - } - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - this._retrySubscription = null; - } - if (this._navigationEvents) { - this._navigationEvents.unsubscribe(); - this._navigationEvents = null; - } - this._knownPhishingDomains.clear(); + this._destroy$.next(); + this._destroy$.complete(); + this._destroy$ = new Subject(); + this._caughtTabs.clear(); - this._lastUpdateTime = 0; - this._isUpdating = false; - this._isInitialized = false; - this._retryCount = 0; // Manually type cast to satisfy the listener signature due to the mixture // of static and instance methods in this class. To be fixed when refactoring diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index e218abd2d10..d44a3d2a2e7 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.10.1", + "version": "2025.11.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 6f4fc905f44..b6381201c7d 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.10.1", + "version": "2025.11.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html index 0a52518b250..bce2b5033ae 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html @@ -8,7 +8,7 @@
  • diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 13c4207992c..4571116312c 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -147,18 +147,6 @@ export class AppComponent implements OnDestroy, OnInit { } break; } - case "premiumRequired": { - const premiumConfirmed = await this.dialogService.openSimpleDialog({ - title: { key: "premiumRequired" }, - content: { key: "premiumRequiredDesc" }, - acceptButtonText: { key: "upgrade" }, - type: "success", - }); - if (premiumConfirmed) { - await this.router.navigate(["settings/subscription/premium"]); - } - break; - } case "emailVerificationRequired": { const emailVerificationConfirmed = await this.dialogService.openSimpleDialog({ title: { key: "emailVerificationRequired" }, diff --git a/apps/web/src/app/auth/recover-two-factor.component.ts b/apps/web/src/app/auth/recover-two-factor.component.ts index dc85668c8ec..9c033b88a75 100644 --- a/apps/web/src/app/auth/recover-two-factor.component.ts +++ b/apps/web/src/app/auth/recover-two-factor.component.ts @@ -113,14 +113,37 @@ export class RecoverTwoFactorComponent implements OnInit { await this.router.navigate(["/settings/security/two-factor"]); } catch (error: unknown) { if (error instanceof ErrorResponse) { - this.logService.error("Error logging in automatically: ", error.message); - - if (error.message.includes("Two-step token is invalid")) { - this.formGroup.get("recoveryCode")?.setErrors({ - invalidRecoveryCode: { message: this.i18nService.t("invalidRecoveryCode") }, + if ( + error.message.includes( + "Two-factor recovery has been performed. SSO authentication is required.", + ) + ) { + // [PM-21153]: Organization users with as SSO requirement need to be able to recover 2FA, + // but still be bound by the SSO requirement to log in. Therefore, we show a success toast for recovering 2FA, + // but then inform them that they need to log in via SSO and redirect them to the login page. + // The response tested here is a specific message for this scenario from request validation. + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("twoStepRecoverDisabled"), }); + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("ssoLoginIsRequired"), + }); + + await this.router.navigate(["/login"]); } else { - this.validationService.showError(error.message); + this.logService.error("Error logging in automatically: ", error.message); + + if (error.message.includes("Two-step token is invalid")) { + this.formGroup.get("recoveryCode")?.setErrors({ + invalidRecoveryCode: { message: this.i18nService.t("invalidRecoveryCode") }, + }); + } else { + this.validationService.showError(error.message); + } } } else { this.logService.error("Error logging in automatically: ", error); diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts index c2b8127ec34..7ef94706ef6 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts @@ -96,15 +96,6 @@ export class EmergencyAccessComponent implements OnInit { this.loaded = true; } - async premiumRequired() { - const canAccessPremium = await firstValueFrom(this.canAccessPremium$); - - if (!canAccessPremium) { - this.messagingService.send("premiumRequired"); - return; - } - } - edit = async (details: GranteeEmergencyAccess) => { const canAccessPremium = await firstValueFrom(this.canAccessPremium$); const dialogRef = EmergencyAccessAddEditComponent.open(this.dialogService, { diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts index ef4d647a7d0..024455cc1bf 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts @@ -3,7 +3,6 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { first, - firstValueFrom, lastValueFrom, Observable, Subject, @@ -264,13 +263,6 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { } } - async premiumRequired() { - if (!(await firstValueFrom(this.canAccessPremium$))) { - this.messagingService.send("premiumRequired"); - return; - } - } - protected getTwoFactorProviders() { return this.twoFactorApiService.getTwoFactorProviders(); } diff --git a/apps/web/src/app/billing/guards/has-premium.guard.ts b/apps/web/src/app/billing/guards/has-premium.guard.ts index 61853b25cb8..f10e75d8268 100644 --- a/apps/web/src/app/billing/guards/has-premium.guard.ts +++ b/apps/web/src/app/billing/guards/has-premium.guard.ts @@ -1,21 +1,21 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, - RouterStateSnapshot, - Router, CanActivateFn, + Router, + RouterStateSnapshot, UrlTree, } from "@angular/router"; -import { Observable, of } from "rxjs"; +import { from, Observable, of } from "rxjs"; import { switchMap, tap } from "rxjs/operators"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; /** - * CanActivate guard that checks if the user has premium and otherwise triggers the "premiumRequired" - * message and blocks navigation. + * CanActivate guard that checks if the user has premium and otherwise triggers the premium upgrade + * flow and blocks navigation. */ export function hasPremiumGuard(): CanActivateFn { return ( @@ -23,7 +23,7 @@ export function hasPremiumGuard(): CanActivateFn { _state: RouterStateSnapshot, ): Observable => { const router = inject(Router); - const messagingService = inject(MessagingService); + const premiumUpgradePromptService = inject(PremiumUpgradePromptService); const billingAccountProfileStateService = inject(BillingAccountProfileStateService); const accountService = inject(AccountService); @@ -33,10 +33,14 @@ export function hasPremiumGuard(): CanActivateFn { ? billingAccountProfileStateService.hasPremiumFromAnySource$(account.id) : of(false), ), - tap((userHasPremium: boolean) => { + switchMap((userHasPremium: boolean) => { + // Can't call async method inside observables so instead, wait for service then switch back to the boolean if (!userHasPremium) { - messagingService.send("premiumRequired"); + return from(premiumUpgradePromptService.promptForPremium()).pipe( + switchMap(() => of(userHasPremium)), + ); } + return of(userHasPremium); }), // Prevent trapping the user on the login page, since that's an awful UX flow tap((userHasPremium: boolean) => { diff --git a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts index d25e035d1be..334e84d1451 100644 --- a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts @@ -16,6 +16,11 @@ import { import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { @@ -28,12 +33,7 @@ import { import { PricingCardComponent } from "@bitwarden/pricing"; import { I18nPipe } from "@bitwarden/ui-common"; -import { SubscriptionPricingService } from "../../services/subscription-pricing.service"; import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types"; -import { - PersonalSubscriptionPricingTier, - PersonalSubscriptionPricingTierIds, -} from "../../types/subscription-pricing-tier"; import { UnifiedUpgradeDialogComponent, UnifiedUpgradeDialogParams, @@ -91,7 +91,7 @@ export class PremiumVNextComponent { private platformUtilsService: PlatformUtilsService, private syncService: SyncService, private billingAccountProfileStateService: BillingAccountProfileStateService, - private subscriptionPricingService: SubscriptionPricingService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, private router: Router, private activatedRoute: ActivatedRoute, ) { diff --git a/apps/web/src/app/billing/individual/premium/premium.component.ts b/apps/web/src/app/billing/individual/premium/premium.component.ts index 6754f4c9f50..62d62331b94 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -5,6 +5,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { + catchError, combineLatest, concatMap, filter, @@ -12,10 +13,9 @@ import { map, Observable, of, + shareReplay, startWith, switchMap, - catchError, - shareReplay, } from "rxjs"; import { debounceTime } from "rxjs/operators"; @@ -23,6 +23,8 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; +import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -35,12 +37,10 @@ import { getBillingAddressFromForm, } from "@bitwarden/web-vault/app/billing/payment/components"; import { - tokenizablePaymentMethodToLegacyEnum, NonTokenizablePaymentMethods, + tokenizablePaymentMethodToLegacyEnum, } from "@bitwarden/web-vault/app/billing/payment/types"; -import { SubscriptionPricingService } from "@bitwarden/web-vault/app/billing/services/subscription-pricing.service"; import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types"; -import { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -137,7 +137,7 @@ export class PremiumComponent { private accountService: AccountService, private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, - private subscriptionPricingService: SubscriptionPricingService, + private subscriptionPricingService: DefaultSubscriptionPricingService, ) { this.isSelfHost = this.platformUtilsService.isSelfHost(); diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts index d0960251724..32c67df1434 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts @@ -4,13 +4,13 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { mock } from "jest-mock-extended"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -import { UserId } from "@bitwarden/common/types/guid"; -import { DIALOG_DATA, DialogRef } from "@bitwarden/components"; - import { PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, -} from "../../../types/subscription-pricing-tier"; +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DIALOG_DATA, DialogRef } from "@bitwarden/components"; + import { UpgradeAccountComponent, UpgradeAccountStatus, diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index 077490cef43..07b21a9fb4b 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -4,6 +4,7 @@ import { Component, Inject, OnInit, signal } from "@angular/core"; import { Router } from "@angular/router"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { PersonalSubscriptionPricingTierId } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { ButtonModule, @@ -15,7 +16,6 @@ import { import { AccountBillingClient, TaxClient } from "../../../clients"; import { BillingServicesModule } from "../../../services"; -import { PersonalSubscriptionPricingTierId } from "../../../types/subscription-pricing-tier"; import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component"; import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service"; import { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts index a6038873e83..add0eb0a011 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts @@ -4,15 +4,15 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PricingCardComponent } from "@bitwarden/pricing"; import { BillingServicesModule } from "../../../services"; -import { SubscriptionPricingService } from "../../../services/subscription-pricing.service"; -import { - PersonalSubscriptionPricingTier, - PersonalSubscriptionPricingTierIds, -} from "../../../types/subscription-pricing-tier"; import { UpgradeAccountComponent, UpgradeAccountStatus } from "./upgrade-account.component"; @@ -20,7 +20,7 @@ describe("UpgradeAccountComponent", () => { let sut: UpgradeAccountComponent; let fixture: ComponentFixture; const mockI18nService = mock(); - const mockSubscriptionPricingService = mock(); + const mockSubscriptionPricingService = mock(); // Mock pricing tiers data const mockPricingTiers: PersonalSubscriptionPricingTier[] = [ @@ -57,7 +57,10 @@ describe("UpgradeAccountComponent", () => { imports: [NoopAnimationsModule, UpgradeAccountComponent, PricingCardComponent, CdkTrapFocus], providers: [ { provide: I18nService, useValue: mockI18nService }, - { provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, ], }) .overrideComponent(UpgradeAccountComponent, { @@ -170,7 +173,10 @@ describe("UpgradeAccountComponent", () => { ], providers: [ { provide: I18nService, useValue: mockI18nService }, - { provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, ], }) .overrideComponent(UpgradeAccountComponent, { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts index 780b6bed433..a4089d7a47a 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts @@ -2,22 +2,23 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CommonModule } from "@angular/common"; import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { catchError, of } from "rxjs"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; -import { ButtonType, DialogModule } from "@bitwarden/components"; -import { PricingCardComponent } from "@bitwarden/pricing"; - -import { SharedModule } from "../../../../shared"; -import { BillingServicesModule } from "../../../services"; -import { SubscriptionPricingService } from "../../../services/subscription-pricing.service"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, SubscriptionCadence, SubscriptionCadenceIds, -} from "../../../types/subscription-pricing-tier"; +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { ButtonType, DialogModule, ToastService } from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; + +import { SharedModule } from "../../../../shared"; +import { BillingServicesModule } from "../../../services"; export const UpgradeAccountStatus = { Closed: "closed", @@ -72,14 +73,26 @@ export class UpgradeAccountComponent implements OnInit { constructor( private i18nService: I18nService, - private subscriptionPricingService: SubscriptionPricingService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, + private toastService: ToastService, private destroyRef: DestroyRef, ) {} ngOnInit(): void { this.subscriptionPricingService .getPersonalSubscriptionPricingTiers$() - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe( + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("unexpectedError"), + }); + this.loading.set(false); + return of([]); + }), + takeUntilDestroyed(this.destroyRef), + ) .subscribe((plans) => { this.setupCardDetails(plans); this.loading.set(false); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts index 11b1787e90e..787936c102e 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts @@ -119,14 +119,13 @@ describe("UpgradeNavButtonComponent", () => { ); }); - it("should refresh token and sync after upgrading to premium", async () => { + it("should full sync after upgrading to premium", async () => { const mockDialogRef = mock>(); mockDialogRef.closed = of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium }); mockDialogService.open.mockReturnValue(mockDialogRef); await component.upgrade(); - expect(mockApiService.refreshIdentityToken).toHaveBeenCalled(); expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); }); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts index 57d3b996e90..4dda16674ff 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts @@ -60,7 +60,6 @@ export class UpgradeNavButtonComponent { const result = await lastValueFrom(dialogRef.closed); if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium) { - await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); } else if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies) { const redirectUrl = `/organizations/${result.organizationId}/vault`; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 614fc862577..daca452c174 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -11,6 +11,7 @@ import { OrganizationResponse } from "@bitwarden/common/admin-console/models/res import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; import { LogService } from "@bitwarden/logging"; @@ -27,7 +28,6 @@ import { NonTokenizedPaymentMethod, TokenizedPaymentMethod, } from "../../../../payment/types"; -import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier"; import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service"; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index e175363af33..d14a1e40796 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -12,6 +12,11 @@ import { SubscriptionInformation, } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { LogService } from "@bitwarden/logging"; @@ -30,11 +35,6 @@ import { TokenizedPaymentMethod, } from "../../../../payment/types"; import { mapAccountToSubscriber } from "../../../../types"; -import { - PersonalSubscriptionPricingTier, - PersonalSubscriptionPricingTierId, - PersonalSubscriptionPricingTierIds, -} from "../../../../types/subscription-pricing-tier"; export type PlanDetails = { tier: PersonalSubscriptionPricingTierId; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index a80ff5d720a..208d046caa7 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -24,6 +24,12 @@ import { } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; @@ -43,13 +49,7 @@ import { TokenizedPaymentMethod, } from "../../../payment/types"; import { BillingServicesModule } from "../../../services"; -import { SubscriptionPricingService } from "../../../services/subscription-pricing.service"; import { BitwardenSubscriber } from "../../../types"; -import { - PersonalSubscriptionPricingTier, - PersonalSubscriptionPricingTierId, - PersonalSubscriptionPricingTierIds, -} from "../../../types/subscription-pricing-tier"; import { PaymentFormValues, @@ -128,7 +128,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { constructor( private i18nService: I18nService, - private subscriptionPricingService: SubscriptionPricingService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, private toastService: ToastService, private logService: LogService, private destroyRef: DestroyRef, @@ -145,29 +145,42 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { } this.pricingTiers$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(); - this.pricingTiers$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((plans) => { - const planDetails = plans.find((plan) => plan.id === this.selectedPlanId()); + this.pricingTiers$ + .pipe( + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("error"), + message: this.i18nService.t("unexpectedError"), + }); + this.loading.set(false); + return of([]); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((plans) => { + const planDetails = plans.find((plan) => plan.id === this.selectedPlanId()); - if (planDetails) { - this.selectedPlan = { - tier: this.selectedPlanId(), - details: planDetails, - }; - this.passwordManager = { - name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", - cost: this.selectedPlan.details.passwordManager.annualPrice, - quantity: 1, - cadence: "year", - }; + if (planDetails) { + this.selectedPlan = { + tier: this.selectedPlanId(), + details: planDetails, + }; + this.passwordManager = { + name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + cost: this.selectedPlan.details.passwordManager.annualPrice, + quantity: 1, + cadence: "year", + }; - this.upgradeToMessage = this.i18nService.t( - this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium", - ); - } else { - this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); - return; - } - }); + this.upgradeToMessage = this.i18nService.t( + this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium", + ); + } else { + this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); + return; + } + }); this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe( startWith(this.formGroup.controls.billingAddress.value), diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index b0bdf31076b..9a6106bebd4 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -795,7 +795,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { : this.i18nService.t("organizationUpgraded"), }); - await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); if (!this.acceptingSponsorship && !this.isInTrialFlow) { diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 0fa0b59b3cd..11c9b78aa21 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -675,7 +675,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { }); } - await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); if (!this.acceptingSponsorship && !this.isInTrialFlow) { diff --git a/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.spec.ts b/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.spec.ts new file mode 100644 index 00000000000..086c7504040 --- /dev/null +++ b/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.spec.ts @@ -0,0 +1,147 @@ +import { firstValueFrom } from "rxjs"; + +import { + FakeAccountService, + FakeStateProvider, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { newGuid } from "@bitwarden/guid"; +import { UserId } from "@bitwarden/user-core"; + +import { + PREMIUM_INTEREST_KEY, + WebPremiumInterestStateService, +} from "./web-premium-interest-state.service"; + +describe("WebPremiumInterestStateService", () => { + let service: WebPremiumInterestStateService; + let stateProvider: FakeStateProvider; + let accountService: FakeAccountService; + + const mockUserId = newGuid() as UserId; + const mockUserEmail = "user@example.com"; + + beforeEach(() => { + accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail }); + stateProvider = new FakeStateProvider(accountService); + service = new WebPremiumInterestStateService(stateProvider); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getPremiumInterest", () => { + it("should throw an error when userId is not provided", async () => { + const promise = service.getPremiumInterest(null); + + await expect(promise).rejects.toThrow("UserId is required. Cannot get 'premiumInterest'."); + }); + + it("should return null when no value is set", async () => { + const result = await service.getPremiumInterest(mockUserId); + + expect(result).toBeNull(); + }); + + it("should return true when value is set to true", async () => { + await stateProvider.setUserState(PREMIUM_INTEREST_KEY, true, mockUserId); + + const result = await service.getPremiumInterest(mockUserId); + + expect(result).toBe(true); + }); + + it("should return false when value is set to false", async () => { + await stateProvider.setUserState(PREMIUM_INTEREST_KEY, false, mockUserId); + + const result = await service.getPremiumInterest(mockUserId); + + expect(result).toBe(false); + }); + + it("should use getUserState$ to retrieve the value", async () => { + const getUserStateSpy = jest.spyOn(stateProvider, "getUserState$"); + await stateProvider.setUserState(PREMIUM_INTEREST_KEY, true, mockUserId); + + await service.getPremiumInterest(mockUserId); + + expect(getUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, mockUserId); + }); + }); + + describe("setPremiumInterest", () => { + it("should throw an error when userId is not provided", async () => { + const promise = service.setPremiumInterest(null, true); + + await expect(promise).rejects.toThrow("UserId is required. Cannot set 'premiumInterest'."); + }); + + it("should set the value to true", async () => { + await service.setPremiumInterest(mockUserId, true); + + const result = await firstValueFrom( + stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId), + ); + + expect(result).toBe(true); + }); + + it("should set the value to false", async () => { + await service.setPremiumInterest(mockUserId, false); + + const result = await firstValueFrom( + stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId), + ); + + expect(result).toBe(false); + }); + + it("should update an existing value", async () => { + await service.setPremiumInterest(mockUserId, true); + await service.setPremiumInterest(mockUserId, false); + + const result = await firstValueFrom( + stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId), + ); + + expect(result).toBe(false); + }); + + it("should use setUserState to store the value", async () => { + const setUserStateSpy = jest.spyOn(stateProvider, "setUserState"); + + await service.setPremiumInterest(mockUserId, true); + + expect(setUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, true, mockUserId); + }); + }); + + describe("clearPremiumInterest", () => { + it("should throw an error when userId is not provided", async () => { + const promise = service.clearPremiumInterest(null); + + await expect(promise).rejects.toThrow("UserId is required. Cannot clear 'premiumInterest'."); + }); + + it("should clear the value by setting it to null", async () => { + await service.setPremiumInterest(mockUserId, true); + await service.clearPremiumInterest(mockUserId); + + const result = await firstValueFrom( + stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId), + ); + + expect(result).toBeNull(); + }); + + it("should use setUserState with null to clear the value", async () => { + const setUserStateSpy = jest.spyOn(stateProvider, "setUserState"); + await service.setPremiumInterest(mockUserId, true); + + await service.clearPremiumInterest(mockUserId); + + expect(setUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, null, mockUserId); + }); + }); +}); diff --git a/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.ts b/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.ts new file mode 100644 index 00000000000..f66fba559f4 --- /dev/null +++ b/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; +import { BILLING_MEMORY, StateProvider, UserKeyDefinition } from "@bitwarden/state"; +import { UserId } from "@bitwarden/user-core"; + +export const PREMIUM_INTEREST_KEY = new UserKeyDefinition( + BILLING_MEMORY, + "premiumInterest", + { + deserializer: (value: boolean) => value, + clearOn: ["lock", "logout"], + }, +); + +@Injectable() +export class WebPremiumInterestStateService implements PremiumInterestStateService { + constructor(private stateProvider: StateProvider) {} + + async getPremiumInterest(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot get 'premiumInterest'."); + } + + return await firstValueFrom(this.stateProvider.getUserState$(PREMIUM_INTEREST_KEY, userId)); + } + + async setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot set 'premiumInterest'."); + } + + await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, premiumInterest, userId); + } + + async clearPremiumInterest(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot clear 'premiumInterest'."); + } + + await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, null, userId); + } +} diff --git a/apps/web/src/app/billing/services/stripe.service.ts b/apps/web/src/app/billing/services/stripe.service.ts index f7655ba0c6e..a2eb7cd98f2 100644 --- a/apps/web/src/app/billing/services/stripe.service.ts +++ b/apps/web/src/app/billing/services/stripe.service.ts @@ -226,7 +226,7 @@ export class StripeService { base: { color: null, fontFamily: - 'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif, ' + + 'Inter, "Helvetica Neue", Helvetica, Arial, sans-serif, ' + '"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', fontSize: "16px", fontSmoothing: "antialiased", diff --git a/apps/web/src/app/components/environment-selector/environment-selector.component.html b/apps/web/src/app/components/environment-selector/environment-selector.component.html index 0c93865c900..9862f62c2e2 100644 --- a/apps/web/src/app/components/environment-selector/environment-selector.component.html +++ b/apps/web/src/app/components/environment-selector/environment-selector.component.html @@ -18,7 +18,7 @@ diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 6dff930f9df..f904fa200de 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -14,6 +14,7 @@ import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password"; import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; +import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { CLIENT_TYPE, @@ -57,6 +58,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service"; import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; @@ -96,6 +98,7 @@ import { NoopSdkLoadService } from "@bitwarden/common/platform/services/sdk/noop import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state"; import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service"; +import { SyncService } from "@bitwarden/common/platform/sync/sync.service"; import { DefaultThemeStateService, ThemeStateService, @@ -129,6 +132,7 @@ import { WebSetInitialPasswordService, } from "../auth"; import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service"; +import { WebPremiumInterestStateService } from "../billing/services/premium-interest/web-premium-interest-state.service"; import { HtmlStorageService } from "../core/html-storage.service"; import { I18nService } from "../core/i18n.service"; import { WebFileDownloadService } from "../core/web-file-download.service"; @@ -410,7 +414,21 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService, - deps: [DialogService, Router], + deps: [ + DialogService, + ConfigService, + AccountService, + ApiService, + SyncService, + BillingAccountProfileStateService, + PlatformUtilsService, + Router, + ], + }), + safeProvider({ + provide: PremiumInterestStateService, + useClass: WebPremiumInterestStateService, + deps: [StateProvider], }), safeProvider({ provide: AuthRequestAnsweringService, diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html index dab928e6ec3..a6ae7a246ac 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html @@ -15,14 +15,12 @@

    {{ title }}

    {{ description }}

    - - {{ "premium" | i18n }} - {{ "upgrade" | i18n }} - + @if (requiresPremium) { + + } @else if (requiresUpgrade) { + + {{ "upgrade" | i18n }} + + } diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts index 565035c2c55..87c005ea46b 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts @@ -37,4 +37,8 @@ export class ReportCardComponent { protected get requiresPremium() { return this.variant == ReportVariant.RequiresPremium; } + + protected get requiresUpgrade() { + return this.variant == ReportVariant.RequiresUpgrade; + } } diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts index 50798fea6e1..93ea79c8418 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts @@ -1,14 +1,20 @@ import { importProvidersFrom } from "@angular/core"; import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { of } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { BadgeModule, BaseCardComponent, - IconModule, CardContentComponent, + I18nMockService, + IconModule, } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; @@ -30,6 +36,37 @@ export default { PremiumBadgeComponent, BaseCardComponent, ], + providers: [ + { + provide: AccountService, + useValue: { + activeAccount$: of({ + id: "123", + }), + }, + }, + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + premium: "Premium", + upgrade: "Upgrade", + }); + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: () => of(false), + }, + }, + { + provide: PremiumUpgradePromptService, + useValue: { + promptForPremium: (orgId?: string) => {}, + }, + }, + ], }), applicationConfig({ providers: [importProvidersFrom(PreloadedEnglishI18nModule)], diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts index 5a89eeff803..5a95e332816 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts @@ -1,9 +1,13 @@ import { importProvidersFrom } from "@angular/core"; import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { of } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { BadgeModule, BaseCardComponent, @@ -33,6 +37,28 @@ export default { BaseCardComponent, ], declarations: [ReportCardComponent], + providers: [ + { + provide: AccountService, + useValue: { + activeAccount$: of({ + id: "123", + }), + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: () => of(false), + }, + }, + { + provide: PremiumUpgradePromptService, + useValue: { + promptForPremium: (orgId?: string) => {}, + }, + }, + ], }), applicationConfig({ providers: [importProvidersFrom(PreloadedEnglishI18nModule)], diff --git a/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts b/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts index 59e59a6a500..940a2d4e3a5 100644 --- a/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts +++ b/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { BaseCardComponent, CardContentComponent } from "@bitwarden/components"; import { SharedModule } from "../../../shared/shared.module"; @@ -9,7 +10,13 @@ import { ReportCardComponent } from "./report-card/report-card.component"; import { ReportListComponent } from "./report-list/report-list.component"; @NgModule({ - imports: [CommonModule, SharedModule, BaseCardComponent, CardContentComponent], + imports: [ + CommonModule, + SharedModule, + BaseCardComponent, + CardContentComponent, + PremiumBadgeComponent, + ], declarations: [ReportCardComponent, ReportListComponent], exports: [ReportCardComponent, ReportListComponent], }) diff --git a/apps/web/src/app/key-management/key-rotation/request/master-password-unlock-data.request.ts b/apps/web/src/app/key-management/key-rotation/request/master-password-unlock-data.request.ts index 95e54e16464..965d866c91c 100644 --- a/apps/web/src/app/key-management/key-rotation/request/master-password-unlock-data.request.ts +++ b/apps/web/src/app/key-management/key-rotation/request/master-password-unlock-data.request.ts @@ -9,6 +9,9 @@ export class MasterPasswordUnlockDataRequest { email: string; masterKeyAuthenticationHash: string; + /** + * Also known as masterKeyWrappedUserKey in other parts of the codebase + */ masterKeyEncryptedUserKey: string; masterPasswordHint?: string; @@ -17,7 +20,7 @@ export class MasterPasswordUnlockDataRequest { kdfConfig: KdfConfig, email: string, masterKeyAuthenticationHash: string, - masterKeyEncryptedUserKey: string, + masterKeyWrappedUserKey: string, masterPasswordHash?: string, ) { this.kdfType = kdfConfig.kdfType; @@ -29,7 +32,7 @@ export class MasterPasswordUnlockDataRequest { this.email = email; this.masterKeyAuthenticationHash = masterKeyAuthenticationHash; - this.masterKeyEncryptedUserKey = masterKeyEncryptedUserKey; + this.masterKeyEncryptedUserKey = masterKeyWrappedUserKey; this.masterPasswordHint = masterPasswordHash; } } diff --git a/apps/web/src/app/layouts/header/web-header.component.html b/apps/web/src/app/layouts/header/web-header.component.html index 4b833e771dd..992ba147075 100644 --- a/apps/web/src/app/layouts/header/web-header.component.html +++ b/apps/web/src/app/layouts/header/web-header.component.html @@ -12,7 +12,7 @@

    diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html index d39156ef4a2..9c8f2125614 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html @@ -29,7 +29,7 @@ [href]="more.marketingRoute.route" target="_blank" rel="noreferrer" - class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-bold !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline" + class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline" >
    @@ -47,7 +47,7 @@ *ngIf="!more.marketingRoute.external" [routerLink]="more.marketingRoute.route" rel="noreferrer" - class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-bold !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline" + class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline" >
    diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html b/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html index aa853796971..f2154ec74a3 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html +++ b/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html @@ -14,7 +14,7 @@ [routerLink]="product.appRoute" [ngClass]=" product.isActive - ? 'tw-bg-primary-600 tw-font-bold !tw-text-contrast tw-ring-offset-2 hover:tw-bg-primary-600' + ? 'tw-bg-primary-600 tw-font-medium !tw-text-contrast tw-ring-offset-2 hover:tw-bg-primary-600' : '' " class="tw-group/product-link tw-flex tw-h-24 tw-w-28 tw-flex-col tw-items-center tw-justify-center tw-rounded tw-p-1 tw-text-primary-600 tw-outline-none hover:tw-bg-background-alt hover:tw-text-primary-700 hover:tw-no-underline focus-visible:!tw-ring-2 focus-visible:!tw-ring-primary-700" diff --git a/apps/web/src/app/layouts/user-layout.component.html b/apps/web/src/app/layouts/user-layout.component.html index 530d4caca03..23f22d263cf 100644 --- a/apps/web/src/app/layouts/user-layout.component.html +++ b/apps/web/src/app/layouts/user-layout.component.html @@ -20,10 +20,12 @@ *ngIf="showSubscription$ | async" > - + @if (showEmergencyAccess()) { + + } diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 9642803ef30..52e5b65a2e8 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -1,14 +1,20 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { Component, OnInit, Signal } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; import { RouterModule } from "@angular/router"; -import { Observable, switchMap } from "rxjs"; +import { combineLatest, map, Observable, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PasswordManagerLogo } from "@bitwarden/assets/svg"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { IconModule } from "@bitwarden/components"; @@ -32,6 +38,7 @@ import { WebLayoutModule } from "./web-layout.module"; }) export class UserLayoutComponent implements OnInit { protected readonly logo = PasswordManagerLogo; + protected readonly showEmergencyAccess: Signal; protected hasFamilySponsorshipAvailable$: Observable; protected showSponsoredFamilies$: Observable; protected showSubscription$: Observable; @@ -40,12 +47,33 @@ export class UserLayoutComponent implements OnInit { private syncService: SyncService, private billingAccountProfileStateService: BillingAccountProfileStateService, private accountService: AccountService, + private policyService: PolicyService, + private configService: ConfigService, ) { this.showSubscription$ = this.accountService.activeAccount$.pipe( switchMap((account) => this.billingAccountProfileStateService.canViewSubscription$(account.id), ), ); + + this.showEmergencyAccess = toSignal( + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId), + ), + ), + ]).pipe( + map(([enabled, policyAppliesToUser]) => { + if (!enabled || !policyAppliesToUser) { + return true; + } + return false; + }), + ), + ); } async ngOnInit() { diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 319adb1d8c6..8e2d770f1e4 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -47,11 +47,13 @@ import { TwoFactorAuthGuard, NewDeviceVerificationComponent, } from "@bitwarden/auth/angular"; +import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; import { LockComponent } from "@bitwarden/key-management-ui"; import { flagEnabled, Flags } from "../utils/flags"; +import { organizationPolicyGuard } from "./admin-console/organizations/guards/org-policy.guard"; import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/manage/verify-recover-delete-org.component"; import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component"; import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component"; @@ -687,11 +689,13 @@ const routes: Routes = [ { path: "", component: EmergencyAccessComponent, + canActivate: [organizationPolicyGuard(canAccessEmergencyAccess)], data: { titleId: "emergencyAccess" } satisfies RouteDataProperties, }, { path: ":id", component: EmergencyAccessViewComponent, + canActivate: [organizationPolicyGuard(canAccessEmergencyAccess)], data: { titleId: "emergencyAccess" } satisfies RouteDataProperties, }, ], diff --git a/apps/web/src/app/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index b79f50311ed..b8538606aec 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -21,7 +21,7 @@
    {{ "filters" | i18n }} diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts index e45a82d82ba..6716cde629a 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts @@ -6,11 +6,14 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogRef, DIALOG_DATA, DialogService, ToastService } from "@bitwarden/components"; @@ -73,6 +76,7 @@ describe("VaultItemDialogComponent", () => { { provide: LogService, useValue: {} }, { provide: CipherService, useValue: {} }, { provide: AccountService, useValue: { activeAccount$: { pipe: () => ({}) } } }, + { provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false) } }, { provide: Router, useValue: {} }, { provide: ActivatedRoute, useValue: {} }, { @@ -84,6 +88,8 @@ describe("VaultItemDialogComponent", () => { { provide: ApiService, useValue: {} }, { provide: EventCollectionService, useValue: {} }, { provide: RoutedVaultFilterService, useValue: {} }, + { provide: SyncService, useValue: {} }, + { provide: PlatformUtilsService, useValue: {} }, ], }).compileComponents(); diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index b9a3bbfdd19..7bdd290336d 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -65,6 +65,7 @@ import { SyncService } from "@bitwarden/common/platform/sync"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -326,6 +327,7 @@ export class VaultComponent implements OnInit, OnDestr private organizationWarningsService: OrganizationWarningsService, private policyService: PolicyService, private unifiedUpgradePromptService: UnifiedUpgradePromptService, + private premiumUpgradePromptService: PremiumUpgradePromptService, ) {} async ngOnInit() { @@ -867,7 +869,7 @@ export class VaultComponent implements OnInit, OnDestr } if (cipher.organizationId == null && !this.canAccessPremium) { - this.messagingService.send("premiumRequired"); + await this.premiumUpgradePromptService.promptForPremium(); return; } else if (cipher.organizationId != null) { const org = await firstValueFrom( diff --git a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts index 1f34b823aec..ad16baee42e 100644 --- a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts @@ -2,8 +2,19 @@ import { TestBed } from "@angular/core/testing"; import { Router } from "@angular/router"; import { lastValueFrom, of } from "rxjs"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogRef, DialogService } from "@bitwarden/components"; +import { + UnifiedUpgradeDialogComponent, + UnifiedUpgradeDialogStatus, +} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component"; import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component"; @@ -13,13 +24,27 @@ describe("WebVaultPremiumUpgradePromptService", () => { let service: WebVaultPremiumUpgradePromptService; let dialogServiceMock: jest.Mocked; let routerMock: jest.Mocked; - let dialogRefMock: jest.Mocked>; + let dialogRefMock: jest.Mocked; + let configServiceMock: jest.Mocked; + let accountServiceMock: jest.Mocked; + let apiServiceMock: jest.Mocked; + let syncServiceMock: jest.Mocked; + let billingAccountProfileServiceMock: jest.Mocked; + let platformUtilsServiceMock: jest.Mocked; beforeEach(() => { dialogServiceMock = { openSimpleDialog: jest.fn(), } as unknown as jest.Mocked; + configServiceMock = { + getFeatureFlag: jest.fn().mockReturnValue(false), + } as unknown as jest.Mocked; + + accountServiceMock = { + activeAccount$: of({ id: "user-123" }), + } as unknown as jest.Mocked; + routerMock = { navigate: jest.fn(), } as unknown as jest.Mocked; @@ -28,12 +53,34 @@ describe("WebVaultPremiumUpgradePromptService", () => { close: jest.fn(), } as unknown as jest.Mocked>; + apiServiceMock = { + refreshIdentityToken: jest.fn().mockReturnValue({}), + } as unknown as jest.Mocked; + + syncServiceMock = { + fullSync: jest.fn(), + } as unknown as jest.Mocked; + + billingAccountProfileServiceMock = { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)), + } as unknown as jest.Mocked; + + platformUtilsServiceMock = { + isSelfHost: jest.fn().mockReturnValue(false), + } as unknown as jest.Mocked; + TestBed.configureTestingModule({ providers: [ WebVaultPremiumUpgradePromptService, { provide: DialogService, useValue: dialogServiceMock }, { provide: Router, useValue: routerMock }, { provide: DialogRef, useValue: dialogRefMock }, + { provide: ConfigService, useValue: configServiceMock }, + { provide: AccountService, useValue: accountServiceMock }, + { provide: ApiService, useValue: apiServiceMock }, + { provide: SyncService, useValue: syncServiceMock }, + { provide: BillingAccountProfileStateService, useValue: billingAccountProfileServiceMock }, + { provide: PlatformUtilsService, useValue: platformUtilsServiceMock }, ], }); @@ -84,4 +131,144 @@ describe("WebVaultPremiumUpgradePromptService", () => { expect(routerMock.navigate).not.toHaveBeenCalled(); expect(dialogRefMock.close).not.toHaveBeenCalled(); }); + + describe("premium status check", () => { + it("should not prompt if user already has premium (feature flag off)", async () => { + configServiceMock.getFeatureFlag.mockReturnValue(Promise.resolve(false)); + billingAccountProfileServiceMock.hasPremiumFromAnySource$.mockReturnValue(of(true)); + + await service.promptForPremium(); + + expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled(); + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it("should not prompt if user already has premium (feature flag on)", async () => { + configServiceMock.getFeatureFlag.mockImplementation((flag: FeatureFlag) => { + if (flag === FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog) { + return Promise.resolve(true); + } + return Promise.resolve(false); + }); + billingAccountProfileServiceMock.hasPremiumFromAnySource$.mockReturnValue(of(true)); + + const unifiedDialogRefMock = { + closed: of({ status: UnifiedUpgradeDialogStatus.Closed }), + close: jest.fn(), + } as any; + jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock); + + await service.promptForPremium(); + + expect(UnifiedUpgradeDialogComponent.open).not.toHaveBeenCalled(); + expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled(); + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + }); + + describe("new premium upgrade dialog with post-upgrade actions", () => { + beforeEach(() => { + configServiceMock.getFeatureFlag.mockImplementation((flag: FeatureFlag) => { + if (flag === FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog) { + return Promise.resolve(true); + } + return Promise.resolve(false); + }); + }); + + describe("when self-hosted", () => { + beforeEach(() => { + platformUtilsServiceMock.isSelfHost.mockReturnValue(true); + }); + + it("should navigate to subscription page instead of opening dialog", async () => { + await service.promptForPremium(); + + expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]); + expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled(); + }); + }); + + describe("when not self-hosted", () => { + beforeEach(() => { + platformUtilsServiceMock.isSelfHost.mockReturnValue(false); + }); + + it("should full sync when user upgrades to premium", async () => { + const unifiedDialogRefMock = { + closed: of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium }), + close: jest.fn(), + } as any; + jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock); + + await service.promptForPremium(); + + expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, { + data: { + account: { id: "user-123" }, + planSelectionStepTitleOverride: "upgradeYourPlan", + hideContinueWithoutUpgradingButton: true, + }, + }); + expect(syncServiceMock.fullSync).toHaveBeenCalledWith(true); + }); + + it("should full sync when user upgrades to families", async () => { + const unifiedDialogRefMock = { + closed: of({ status: UnifiedUpgradeDialogStatus.UpgradedToFamilies }), + close: jest.fn(), + } as any; + jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock); + + await service.promptForPremium(); + + expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, { + data: { + account: { id: "user-123" }, + planSelectionStepTitleOverride: "upgradeYourPlan", + hideContinueWithoutUpgradingButton: true, + }, + }); + expect(syncServiceMock.fullSync).toHaveBeenCalledWith(true); + }); + + it("should not refresh or sync when user closes dialog without upgrading", async () => { + const unifiedDialogRefMock = { + closed: of({ status: UnifiedUpgradeDialogStatus.Closed }), + close: jest.fn(), + } as any; + jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock); + + await service.promptForPremium(); + + expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, { + data: { + account: { id: "user-123" }, + planSelectionStepTitleOverride: "upgradeYourPlan", + hideContinueWithoutUpgradingButton: true, + }, + }); + expect(apiServiceMock.refreshIdentityToken).not.toHaveBeenCalled(); + expect(syncServiceMock.fullSync).not.toHaveBeenCalled(); + }); + + it("should not open new dialog if organizationId is provided", async () => { + const organizationId = "test-org-id" as OrganizationId; + dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true))); + + const openSpy = jest.spyOn(UnifiedUpgradeDialogComponent, "open"); + openSpy.mockClear(); + + await service.promptForPremium(organizationId); + + expect(openSpy).not.toHaveBeenCalled(); + expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "upgradeOrganization" }, + content: { key: "upgradeOrganizationDesc" }, + acceptButtonText: { key: "upgradeOrganization" }, + type: "info", + }); + }); + }); + }); }); diff --git a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts index 87fcdc345d8..c456cf6cc13 100644 --- a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts @@ -1,10 +1,21 @@ import { Injectable, Optional } from "@angular/core"; import { Router } from "@angular/router"; -import { Subject } from "rxjs"; +import { firstValueFrom, lastValueFrom, Subject } from "rxjs"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { DialogRef, DialogService } from "@bitwarden/components"; +import { + UnifiedUpgradeDialogComponent, + UnifiedUpgradeDialogStatus, +} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component"; import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component"; @@ -15,14 +26,44 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt constructor( private dialogService: DialogService, + private configService: ConfigService, + private accountService: AccountService, + private apiService: ApiService, + private syncService: SyncService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private platformUtilsService: PlatformUtilsService, private router: Router, @Optional() private dialog?: DialogRef, ) {} + private readonly subscriptionPageRoute = "settings/subscription/premium"; /** * Prompts the user for a premium upgrade. */ async promptForPremium(organizationId?: OrganizationId) { + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return; + } + const hasPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ); + if (hasPremium) { + // Already has premium, don't prompt + return; + } + + const showNewDialog = await this.configService.getFeatureFlag( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + + // Per conversation in PM-23713, retain the existing upgrade org flow for now, will be addressed + // as a part of https://bitwarden.atlassian.net/browse/PM-25507 + if (showNewDialog && !organizationId) { + await this.promptForPremiumVNext(account); + return; + } + let confirmed = false; let route: string[] | null = null; @@ -44,7 +85,7 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt type: "success", }); if (confirmed) { - route = ["settings/subscription/premium"]; + route = [this.subscriptionPageRoute]; } } @@ -57,4 +98,31 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt this.dialog.close(VaultItemDialogResult.PremiumUpgrade); } } + + private async promptForPremiumVNext(account: Account) { + await (this.platformUtilsService.isSelfHost() + ? this.redirectToSubscriptionPage() + : this.openUpgradeDialog(account)); + } + + private async redirectToSubscriptionPage() { + await this.router.navigate([this.subscriptionPageRoute]); + } + + private async openUpgradeDialog(account: Account) { + const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, { + data: { + account, + planSelectionStepTitleOverride: "upgradeYourPlan", + hideContinueWithoutUpgradingButton: true, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if ( + result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium || + result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies + ) { + await this.syncService.fullSync(true); + } + } } diff --git a/apps/web/src/images/loading-white.svg b/apps/web/src/images/loading-white.svg index ef5970da42e..f5546e56340 100644 --- a/apps/web/src/images/loading-white.svg +++ b/apps/web/src/images/loading-white.svg @@ -1,5 +1,5 @@  - Loading... diff --git a/apps/web/src/images/loading.svg b/apps/web/src/images/loading.svg index 5f4102a5921..e05a42f6c70 100644 --- a/apps/web/src/images/loading.svg +++ b/apps/web/src/images/loading.svg @@ -1,5 +1,5 @@  - Loading... diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 634e60db0c5..e91464cb174 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -26,6 +26,9 @@ "reviewAtRiskPasswords": { "message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords." }, + "reviewAtRiskLoginsPrompt": { + "message": "Review at-risk logins" + }, "dataLastUpdated": { "message": "Data last updated: $DATE$", "placeholders": { @@ -127,6 +130,9 @@ } } }, + "criticalApplicationsMarked": { + "message": "critical applications marked" + }, "countOfCriticalApplications": { "message": "$COUNT$ critical applications", "placeholders": { @@ -235,6 +241,15 @@ "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "criticalApplicationsMarkedSuccess": { + "message": "$COUNT$ applications marked as critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "applicationsMarkedAsCriticalFail": { "message": "Failed to mark applications as critical" }, @@ -259,6 +274,12 @@ "membersWithAccessToAtRiskItemsForCriticalApps": { "message": "Members with access to at-risk items for critical applications" }, + "membersWithAtRiskPasswords": { + "message": "Members with at-risk passwords" + }, + "membersWillReceiveNotification": { + "message": "Members will receive a notification to resolve at-risk logins through the browser extension." + }, "membersAtRiskCount": { "message": "$COUNT$ members at-risk", "placeholders": { @@ -352,8 +373,11 @@ "prioritizeCriticalApplications": { "message": "Prioritize critical applications" }, - "atRiskItems": { - "message": "At-risk items" + "selectCriticalApplicationsDescription": { + "message": "Select which applications are most critical to your organization, then assign security tasks to members to resolve risks." + }, + "clickIconToMarkAppAsCritical": { + "message": "Click the star icon to mark an app as critical" }, "markAsCriticalPlaceholder": { "message": "Mark as critical functionality will be implemented in a future update" @@ -361,15 +385,6 @@ "applicationReviewSaved": { "message": "Application review saved" }, - "applicationsMarkedAsCritical": { - "message": "$COUNT$ applications marked as critical", - "placeholders": { - "count": { - "content": "$1", - "example": "3" - } - } - }, "newApplicationsReviewed": { "message": "New applications reviewed" }, @@ -841,6 +856,9 @@ "favorites": { "message": "Favorites" }, + "taskSummary": { + "message": "Task summary" + }, "types": { "message": "Types" }, @@ -5814,8 +5832,8 @@ "autoConfirmSingleOrgRequired": { "message": "Single organization policy required. " }, - "autoConfirmSingleOrgRequiredDescription": { - "message": "Anyone part of more than one organization will have their access revoked until they leave the other organizations." + "autoConfirmSingleOrgRequiredDesc": { + "message": "All members must only belong to this organization to activate this automation." }, "autoConfirmSingleOrgExemption": { "message": "Single organization policy will extend to all roles. " @@ -6547,11 +6565,11 @@ "updateWeakMasterPasswordWarning": { "message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." }, - "automaticAppLogin": { - "message": "Automatically log in users for allowed applications" + "automaticAppLoginWithSSO": { + "message": "Automatic login with SSO" }, - "automaticAppLoginDesc": { - "message": "Login forms will automatically be filled and submitted for apps launched from your configured identity provider." + "automaticAppLoginWithSSODesc": { + "message": "Extend SSO security and convenience to unmanaged apps. When users launch an app from your identity provider, their login details are automatically filled and submitted, creating a one-click, secure flow from the identity provider to the app." }, "automaticAppLoginIdpHostLabel": { "message": "Identity provider host" @@ -7326,6 +7344,9 @@ "accessDenied": { "message": "Access denied. You do not have permission to view this page." }, + "noPageAccess": { + "message": "You do not have access to this page" + }, "masterPassword": { "message": "Master password" }, @@ -9784,6 +9805,9 @@ "assignTasks": { "message": "Assign tasks" }, + "assignTasksToMembers": { + "message": "Assign tasks to members for guided resolution" + }, "assignToCollections": { "message": "Assign to collections" }, diff --git a/apps/web/src/scss/tailwind.css b/apps/web/src/scss/tailwind.css index 57332e033b9..a6641a2446e 100644 --- a/apps/web/src/scss/tailwind.css +++ b/apps/web/src/scss/tailwind.css @@ -86,11 +86,11 @@ */ @layer components { .tw-h1 { - @apply tw-text-3xl tw-font-semibold; + @apply tw-text-3xl tw-font-medium; } .tw-btn { - @apply tw-font-semibold tw-py-1.5 tw-px-3 tw-rounded-full tw-transition tw-border-2 tw-border-solid tw-text-center tw-no-underline focus:tw-outline-none; + @apply tw-font-medium tw-py-1.5 tw-px-3 tw-rounded-full tw-transition tw-border-2 tw-border-solid tw-text-center tw-no-underline focus:tw-outline-none; } .tw-btn-secondary { @@ -100,7 +100,7 @@ } .tw-link { - @apply tw-font-semibold hover:tw-underline hover:tw-decoration-1; + @apply tw-font-medium hover:tw-underline hover:tw-decoration-1; @apply tw-text-primary-600 hover:tw-text-primary-700 focus-visible:before:tw-ring-primary-600; } diff --git a/apps/web/src/scss/vault-filters.scss b/apps/web/src/scss/vault-filters.scss index 52c85e8f0ae..9edf0be023a 100644 --- a/apps/web/src/scss/vault-filters.scss +++ b/apps/web/src/scss/vault-filters.scss @@ -30,7 +30,7 @@ color: rgb(var(--color-primary-600)); } &.active { - font-weight: bold; + font-weight: 500; } } } @@ -59,7 +59,7 @@ > .filter-buttons { .filter-button { color: rgb(var(--color-primary-600)); - font-weight: bold; + font-weight: 500; } .edit-button { diff --git a/apps/web/src/videos/access-intelligence-assign-tasks-dark.mp4 b/apps/web/src/videos/access-intelligence-assign-tasks-dark.mp4 new file mode 100644 index 00000000000..c982d0e9d3f Binary files /dev/null and b/apps/web/src/videos/access-intelligence-assign-tasks-dark.mp4 differ diff --git a/apps/web/src/videos/access-intelligence-assign-tasks.mp4 b/apps/web/src/videos/access-intelligence-assign-tasks.mp4 new file mode 100644 index 00000000000..d6f5e01ae22 Binary files /dev/null and b/apps/web/src/videos/access-intelligence-assign-tasks.mp4 differ diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts index 650726628c6..387d594d4e3 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts @@ -56,6 +56,7 @@ import { OrganizationReportSummary, ReportStatus, ReportState, + ApplicationHealthReportDetail, } from "../../models/report-models"; import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service"; import { RiskInsightsApiService } from "../api/risk-insights-api.service"; @@ -98,18 +99,28 @@ export class RiskInsightsOrchestratorService { enrichedReportData$ = this._enrichedReportDataSubject.asObservable(); // New applications that haven't been reviewed (reviewedDate === null) - newApplications$: Observable = this.rawReportData$.pipe( + newApplications$: Observable = this.rawReportData$.pipe( map((reportState) => { - if (!reportState.data?.applicationData) { - return []; - } - return reportState.data.applicationData - .filter((app) => app.reviewedDate === null) - .map((app) => app.applicationName); + const reportApplications = reportState.data?.applicationData || []; + + const newApplications = + reportState?.data?.reportData.filter((reportApp) => + reportApplications.some( + (app) => app.applicationName == reportApp.applicationName && app.reviewedDate == null, + ), + ) || []; + return newApplications; + }), + distinctUntilChanged((prev, curr) => { + if (prev.length !== curr.length) { + return false; + } + return prev.every( + (app, i) => + app.applicationName === curr[i].applicationName && + app.atRiskPasswordCount === curr[i].atRiskPasswordCount, + ); }), - distinctUntilChanged( - (prev, curr) => prev.length === curr.length && prev.every((app, i) => app === curr[i]), - ), shareReplay({ bufferSize: 1, refCount: true }), ); @@ -332,9 +343,12 @@ export class RiskInsightsOrchestratorService { } // Create a set for quick lookup of the new critical apps - const newCriticalAppNamesSet = new Set(criticalApplications); + const newCriticalAppNamesSet = criticalApplications.map((ca) => ({ + applicationName: ca, + isCritical: true, + })); const existingApplicationData = report!.applicationData || []; - const updatedApplicationData = this._mergeApplicationData( + const updatedApplicationData = this._updateApplicationData( existingApplicationData, newCriticalAppNamesSet, ); @@ -443,18 +457,18 @@ export class RiskInsightsOrchestratorService { } /** - * Saves review status for new applications and optionally marks selected ones as critical. - * This method: - * 1. Sets reviewedDate to current date for all applications where reviewedDate === null - * 2. Sets isCritical = true for applications in the selectedCriticalApps array + * Saves review status for new applications and optionally marks + * selected ones as critical * - * @param selectedCriticalApps Array of application names to mark as critical (can be empty) + * @param reviewedApplications Array of application names to mark as reviewed * @returns Observable of updated ReportState */ - saveApplicationReviewStatus$(selectedCriticalApps: string[]): Observable { - this.logService.info("[RiskInsightsOrchestratorService] Saving application review status", { - criticalAppsCount: selectedCriticalApps.length, - }); + saveApplicationReviewStatus$( + reviewedApplications: OrganizationReportApplication[], + ): Observable { + this.logService.info( + `[RiskInsightsOrchestratorService] Saving application review status for ${reviewedApplications.length} applications`, + ); return this.rawReportData$.pipe( take(1), @@ -464,16 +478,43 @@ export class RiskInsightsOrchestratorService { this._userId$.pipe(filter((userId) => !!userId)), ), map(([reportState, organizationDetails, userId]) => { + const report = reportState?.data; + if (!report) { + throwError(() => Error("Tried save reviewed applications without a report")); + } + const existingApplicationData = reportState?.data?.applicationData || []; - const updatedApplicationData = this._updateReviewStatusAndCriticalFlags( + const updatedApplicationData = this._updateApplicationData( existingApplicationData, - selectedCriticalApps, + reviewedApplications, ); + // Updated summary data after changing critical apps + const updatedSummaryData = this.reportService.getApplicationsSummary( + report!.reportData, + updatedApplicationData, + ); + // Used for creating metrics with updated application data + const manualEnrichedApplications = report!.reportData.map( + (application): ApplicationHealthReportDetailEnriched => ({ + ...application, + isMarkedAsCritical: this.reportService.isCriticalApplication( + application, + updatedApplicationData, + ), + }), + ); + // For now, merge the report with the critical marking flag to make the enriched type + // We don't care about the individual ciphers in this instance + // After the report and enriched report types are consolidated, this mapping can be removed + // and the class will expose getCriticalApplications + const metrics = this._getReportMetrics(manualEnrichedApplications, updatedSummaryData); + const updatedState = { ...reportState, data: { ...reportState.data, + summaryData: updatedSummaryData, applicationData: updatedApplicationData, }, } as ReportState; @@ -484,9 +525,9 @@ export class RiskInsightsOrchestratorService { criticalApps: updatedApplicationData.filter((app) => app.isCritical).length, }); - return { reportState, organizationDetails, updatedState, userId }; + return { reportState, organizationDetails, updatedState, userId, metrics }; }), - switchMap(({ reportState, organizationDetails, updatedState, userId }) => { + switchMap(({ reportState, organizationDetails, updatedState, userId, metrics }) => { return from( this.riskInsightsEncryptionService.encryptRiskInsightsReport( { @@ -506,10 +547,11 @@ export class RiskInsightsOrchestratorService { organizationDetails, updatedState, encryptedData, + metrics, })), ); }), - switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => { + switchMap(({ reportState, organizationDetails, updatedState, encryptedData, metrics }) => { this.logService.debug( `[RiskInsightsOrchestratorService] Persisting review status - report id: ${reportState?.data?.id}`, ); @@ -521,26 +563,44 @@ export class RiskInsightsOrchestratorService { return of({ ...reportState }); } - return this.reportApiService - .updateRiskInsightsApplicationData$( - reportState.data.id, - organizationDetails.organizationId, - { - data: { - applicationData: encryptedData.encryptedApplicationData.toSdk(), - }, + // Update applications data with critical marking + const updateApplicationsCall = this.reportApiService.updateRiskInsightsApplicationData$( + reportState.data.id, + organizationDetails.organizationId, + { + data: { + applicationData: encryptedData.encryptedApplicationData.toSdk(), }, - ) - .pipe( - map(() => updatedState), - catchError((error: unknown) => { - this.logService.error( - "[RiskInsightsOrchestratorService] Failed to save review status", - error, - ); - return of({ ...reportState, error: "Failed to save application review status" }); - }), - ); + }, + ); + + // Update summary after recomputing + const updateSummaryCall = this.reportApiService.updateRiskInsightsSummary$( + reportState.data.id, + organizationDetails.organizationId, + { + data: { + summaryData: encryptedData.encryptedSummaryData.toSdk(), + metrics: metrics.toRiskInsightsMetricsData(), + }, + }, + ); + + return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe( + map(() => updatedState), + tap((finalState) => { + this._flagForUpdatesSubject.next({ + ...finalState, + }); + }), + catchError((error: unknown) => { + this.logService.error( + "[RiskInsightsOrchestratorService] Failed to save review status", + error, + ); + return of({ ...reportState, error: "Failed to save application review status" }); + }), + ); }), ); } @@ -752,67 +812,40 @@ export class RiskInsightsOrchestratorService { // Updates the existing application data to include critical applications // Does not remove critical applications not in the set - private _mergeApplicationData( + private _updateApplicationData( existingApplications: OrganizationReportApplication[], - criticalApplications: Set, + updatedApplications: (Partial & { applicationName: string })[], ): OrganizationReportApplication[] { - const setToMerge = new Set(criticalApplications); + const arrayToMerge = [...updatedApplications]; const updatedApps = existingApplications.map((app) => { - const foundCritical = setToMerge.has(app.applicationName); + // Check if there is an updated app + const foundUpdatedIndex = arrayToMerge.findIndex( + (ua) => ua.applicationName == app.applicationName, + ); - if (foundCritical) { - setToMerge.delete(app.applicationName); + let foundApp: Partial | null = null; + // Remove the updated app from the list + if (foundUpdatedIndex >= 0) { + foundApp = arrayToMerge[foundUpdatedIndex]; + arrayToMerge.splice(foundUpdatedIndex, 1); } - return { - ...app, - isCritical: foundCritical || app.isCritical, + applicationName: app.applicationName, + isCritical: foundApp?.isCritical || app.isCritical, + reviewedDate: foundApp?.reviewedDate || app.reviewedDate, }; }); - setToMerge.forEach((applicationName) => { - updatedApps.push({ - applicationName, - isCritical: true, + const newElements: OrganizationReportApplication[] = arrayToMerge.map( + (newApp): OrganizationReportApplication => ({ + applicationName: newApp.applicationName, + isCritical: newApp.isCritical ?? false, reviewedDate: null, - }); - }); + }), + ); - return updatedApps; - } - - /** - * Updates review status and critical flags for applications. - * Sets reviewedDate for all apps with null reviewedDate. - * Sets isCritical flag for apps in the criticalApplications array. - * - * @param existingApplications Current application data - * @param criticalApplications Array of application names to mark as critical - * @returns Updated application data with review dates and critical flags - */ - private _updateReviewStatusAndCriticalFlags( - existingApplications: OrganizationReportApplication[], - criticalApplications: string[], - ): OrganizationReportApplication[] { - const criticalSet = new Set(criticalApplications); - const currentDate = new Date(); - - return existingApplications.map((app) => { - const shouldMarkCritical = criticalSet.has(app.applicationName); - const needsReviewDate = app.reviewedDate === null; - - // Only create new object if changes are needed - if (needsReviewDate || shouldMarkCritical) { - return { - ...app, - reviewedDate: needsReviewDate ? currentDate : app.reviewedDate, - isCritical: shouldMarkCritical || app.isCritical, - }; - } - - return app; - }); + return updatedApps.concat(newElements); } // Toggles the isCritical flag on applications via criticalApplicationName diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts index 2dc669f5727..cdfdbe740a0 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts @@ -10,6 +10,8 @@ import { DrawerType, RiskInsightsEnrichedData, ReportStatus, + ApplicationHealthReportDetail, + OrganizationReportApplication, } from "../../models"; import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service"; @@ -38,7 +40,7 @@ export class RiskInsightsDataService { readonly hasCiphers$: Observable = of(null); // New applications that need review (reviewedDate === null) - readonly newApplications$: Observable = of([]); + readonly newApplications$: Observable = of([]); // ------------------------- Drawer Variables --------------------- // Drawer variables unified into a single BehaviorSubject @@ -257,7 +259,7 @@ export class RiskInsightsDataService { return this.orchestrator.removeCriticalApplication$(hostname); } - saveApplicationReviewStatus(selectedCriticalApps: string[]) { + saveApplicationReviewStatus(selectedCriticalApps: OrganizationReportApplication[]) { return this.orchestrator.saveApplicationReviewStatus$(selectedCriticalApps); } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/automatic-app-login.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/automatic-app-login.component.ts index 85110a5af21..eb82dce4383 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/automatic-app-login.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/automatic-app-login.component.ts @@ -11,8 +11,8 @@ import { import { SharedModule } from "@bitwarden/web-vault/app/shared"; export class AutomaticAppLoginPolicy extends BasePolicyEditDefinition { - name = "automaticAppLogin"; - description = "automaticAppLoginDesc"; + name = "automaticAppLoginWithSSO"; + description = "automaticAppLoginWithSSODesc"; type = PolicyType.AutomaticAppLogIn; component = AutomaticAppLoginPolicyComponent; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence-routing.module.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence-routing.module.ts index 2e3c53d8d9f..4a37bea8872 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence-routing.module.ts @@ -9,9 +9,7 @@ const routes: Routes = [ { path: "", pathMatch: "full", redirectTo: "risk-insights" }, { path: "risk-insights", - canActivate: [ - organizationPermissionsGuard((org) => org.useRiskInsights && org.canAccessReports), - ], + canActivate: [organizationPermissionsGuard((org) => org.canAccessReports)], component: RiskInsightsComponent, data: { titleId: "RiskInsights", diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts index 2cb9140f174..c1d2cdda3e2 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts @@ -30,12 +30,14 @@ import { LogService } from "@bitwarden/logging"; import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service"; import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module"; +import { NewApplicationsDialogComponent } from "./activity/application-review-dialog/new-applications-dialog.component"; import { RiskInsightsComponent } from "./risk-insights.component"; import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service"; @NgModule({ - imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule], + imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule, NewApplicationsDialogComponent], providers: [ + safeProvider(DefaultAdminTaskService), safeProvider({ provide: MemberCipherDetailsApiService, useClass: MemberCipherDetailsApiService, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts index 150c66ad2d4..8a2b2825208 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts @@ -1,10 +1,11 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, lastValueFrom } from "rxjs"; import { AllActivitiesService, + ApplicationHealthReportDetail, ReportStatus, RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; @@ -13,6 +14,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getById } from "@bitwarden/common/platform/misc"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; @@ -20,7 +22,7 @@ import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.co import { ActivityCardComponent } from "./activity-card.component"; import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component"; -import { NewApplicationsDialogComponent } from "./new-applications-dialog.component"; +import { NewApplicationsDialogComponent } from "./application-review-dialog/new-applications-dialog.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -40,7 +42,7 @@ export class AllActivityComponent implements OnInit { totalCriticalAppsCount = 0; totalCriticalAppsAtRiskCount = 0; newApplicationsCount = 0; - newApplications: string[] = []; + newApplications: ApplicationHealthReportDetail[] = []; passwordChangeMetricHasProgressBar = false; allAppsHaveReviewDate = false; isAllCaughtUp = false; @@ -127,27 +129,38 @@ export class AllActivityComponent implements OnInit { * Handles the review new applications button click. * Opens a dialog showing the list of new applications that can be marked as critical. */ - onReviewNewApplications = async () => { + async onReviewNewApplications() { + const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); + + if (!organizationId) { + return; + } + + // Pass organizationId via dialog data instead of having the dialog retrieve it from route. + // This ensures organizationId is immediately available when dialog opens, preventing + // timing issues where the dialog's checkForTasksToAssign() method runs before + // organizationId is populated via async route subscription. const dialogRef = NewApplicationsDialogComponent.open(this.dialogService, { newApplications: this.newApplications, + organizationId: organizationId as OrganizationId, }); - await firstValueFrom(dialogRef.closed); - }; + await lastValueFrom(dialogRef.closed); + } /** * Handles the "View at-risk members" link click. * Opens the at-risk members drawer for critical applications only. */ - onViewAtRiskMembers = async () => { + async onViewAtRiskMembers() { await this.dataService.setDrawerForCriticalAtRiskMembers("activityTabAtRiskMembers"); - }; + } /** * Handles the "View at-risk applications" link click. * Opens the at-risk applications drawer for critical applications only. */ - onViewAtRiskApplications = async () => { + async onViewAtRiskApplications() { await this.dataService.setDrawerForCriticalAtRiskApps("activityTabAtRiskApplications"); - }; + } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html new file mode 100644 index 00000000000..875e86ed40b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html @@ -0,0 +1,78 @@ +
    + +
    + +
    + + + {{ atRiskCriticalMembersCount() }} + {{ "membersWithAtRiskPasswords" | i18n }} + for + {{ criticalApplicationsCount() }} + {{ "criticalApplications" | i18n }} + + + +
    + +
    + + {{ atRiskCriticalMembersCount() }} + + + {{ "membersWithAtRiskPasswords" | i18n }} + +
    +
    + + +
    + +
    +
    + + {{ criticalApplicationsCount() }} + + + of {{ totalApplicationsCount() }} total + +
    + + {{ "criticalApplications" | i18n }} at-risk + +
    +
    +
    + + +
    + + + +
    + {{ "membersWillReceiveNotification" | i18n }} +
    +
    +
    +
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts new file mode 100644 index 00000000000..ac1b241a54b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts @@ -0,0 +1,45 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; + +import { + ButtonModule, + CalloutComponent, + IconTileComponent, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { DarkImageSourceDirective } from "@bitwarden/vault"; + +import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service"; +import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; + +/** + * Embedded component for displaying task assignment UI. + * Not a dialog - intended to be embedded within a parent dialog. + * + * Important: This component provides its own instances of AccessIntelligenceSecurityTasksService + * and DefaultAdminTaskService. These services are scoped to this component to ensure proper + * dependency injection when the component is dynamically rendered within the structure. + * Without these providers, Angular would throw NullInjectorError when trying to inject + * DefaultAdminTaskService, which is required by AccessIntelligenceSecurityTasksService. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "dirt-assign-tasks-view", + templateUrl: "./assign-tasks-view.component.html", + imports: [ + CommonModule, + ButtonModule, + TypographyModule, + I18nPipe, + IconTileComponent, + DarkImageSourceDirective, + CalloutComponent, + ], + providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService], +}) +export class AssignTasksViewComponent { + readonly criticalApplicationsCount = input.required(); + readonly totalApplicationsCount = input.required(); + readonly atRiskCriticalMembersCount = input.required(); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html new file mode 100644 index 00000000000..6ac6ea768b5 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html @@ -0,0 +1,97 @@ + + + {{ + currentView() === DialogView.SelectApplications + ? ("prioritizeCriticalApplications" | i18n) + : ("assignTasksToMembers" | i18n) + }} + + +
    + @if (currentView() === DialogView.SelectApplications) { +
    +

    + {{ "selectCriticalApplicationsDescription" | i18n }} +

    + +
    + +

    + {{ "clickIconToMarkAppAsCritical" | i18n }} +

    +
    + + +
    + } + + @if (currentView() === DialogView.AssignTasks) { + + + } +
    + + @if (currentView() === DialogView.SelectApplications) { + + + + + } + @if (currentView() == DialogView.AssignTasks) { + + + + + + } +
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts new file mode 100644 index 00000000000..ff238e2636a --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts @@ -0,0 +1,276 @@ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + Inject, + inject, + signal, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { from, switchMap } from "rxjs"; + +import { + ApplicationHealthReportDetail, + ApplicationHealthReportDetailEnriched, + OrganizationReportApplication, + RiskInsightsDataService, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { + ButtonModule, + DIALOG_DATA, + DialogModule, + DialogRef, + DialogService, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; + +import { AssignTasksViewComponent } from "./assign-tasks-view.component"; +import { ReviewApplicationsViewComponent } from "./review-applications-view.component"; + +export interface NewApplicationsDialogData { + newApplications: ApplicationHealthReportDetail[]; + /** + * Organization ID is passed via dialog data instead of being retrieved from route params. + * This ensures organizationId is available immediately when the dialog opens, + * preventing async timing issues where user clicks "Mark as critical" before + * the route subscription has fired. + */ + organizationId: OrganizationId; +} + +/** + * View states for dialog navigation + * Using const object pattern per ADR-0025 (Deprecate TypeScript Enums) + */ +export const DialogView = Object.freeze({ + SelectApplications: "select", + AssignTasks: "assign", +} as const); + +export type DialogView = (typeof DialogView)[keyof typeof DialogView]; + +// Possible results for closing the dialog +export const NewApplicationsDialogResultType = Object.freeze({ + Close: "close", + Complete: "complete", +} as const); +export type NewApplicationsDialogResultType = + (typeof NewApplicationsDialogResultType)[keyof typeof NewApplicationsDialogResultType]; + +@Component({ + selector: "dirt-new-applications-dialog", + templateUrl: "./new-applications-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ButtonModule, + DialogModule, + TypographyModule, + I18nPipe, + AssignTasksViewComponent, + ReviewApplicationsViewComponent, + ], +}) +export class NewApplicationsDialogComponent { + destroyRef = inject(DestroyRef); + + // View state management + protected readonly currentView = signal(DialogView.SelectApplications); + // Expose DialogView constants to template + protected readonly DialogView = DialogView; + + // Review new applications view + // Applications selected to save as critical applications + protected readonly selectedApplications = signal>(new Set()); + + // Assign tasks variables + readonly criticalApplicationsCount = signal(0); + readonly totalApplicationsCount = signal(0); + readonly atRiskCriticalMembersCount = signal(0); + readonly saving = signal(false); + + // Loading states + protected readonly markingAsCritical = signal(false); + + constructor( + @Inject(DIALOG_DATA) protected dialogParams: NewApplicationsDialogData, + private dialogRef: DialogRef, + private dataService: RiskInsightsDataService, + private toastService: ToastService, + private i18nService: I18nService, + private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, + private logService: LogService, + ) {} + + /** + * Opens the new applications dialog + * @param dialogService The dialog service instance + * @param data Dialog data containing the list of new applications and organizationId + * @returns Dialog reference + */ + static open(dialogService: DialogService, data: NewApplicationsDialogData) { + return dialogService.open( + NewApplicationsDialogComponent, + { + data, + }, + ); + } + + getApplications() { + return this.dialogParams.newApplications; + } + + /** + * Toggles the selection state of an application. + * @param applicationName The application to toggle + */ + toggleSelection(applicationName: string) { + this.selectedApplications.update((current) => { + const temp = new Set(current); + if (temp.has(applicationName)) { + temp.delete(applicationName); + } else { + temp.add(applicationName); + } + return temp; + }); + } + + /** + * Toggles the selection state of all applications. + * If all are selected, unselect all. Otherwise, select all. + */ + toggleAll() { + const allApplicationNames = this.dialogParams.newApplications.map((app) => app.applicationName); + const allSelected = this.selectedApplications().size === allApplicationNames.length; + + this.selectedApplications.update(() => { + return allSelected ? new Set() : new Set(allApplicationNames); + }); + } + + handleMarkAsCritical() { + if (this.markingAsCritical() || this.saving()) { + return; // Prevent action if already processing + } + this.markingAsCritical.set(true); + + const onlyNewCriticalApplications = this.dialogParams.newApplications.filter((newApp) => + this.selectedApplications().has(newApp.applicationName), + ); + + const atRiskCriticalMembersCount = getUniqueMembers( + onlyNewCriticalApplications.flatMap((x) => x.atRiskMemberDetails), + ).length; + this.atRiskCriticalMembersCount.set(atRiskCriticalMembersCount); + + this.currentView.set(DialogView.AssignTasks); + this.markingAsCritical.set(false); + } + + /** + * Handles the assign tasks button click + */ + protected handleAssignTasks() { + if (this.saving()) { + return; // Prevent double-click + } + this.saving.set(true); + + // Create updated organization report application types with new review date + // and critical marking based on selected applications + const newReviewDate = new Date(); + const updatedApplications: OrganizationReportApplication[] = + this.dialogParams.newApplications.map((app) => ({ + applicationName: app.applicationName, + isCritical: this.selectedApplications().has(app.applicationName), + reviewedDate: newReviewDate, + })); + + // Save the application review dates and critical markings + this.dataService + .saveApplicationReviewStatus(updatedApplications) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((updatedState) => { + // After initial save is complete, created the assigned tasks + // for at risk passwords + const updatedStateApplicationData = updatedState?.data?.applicationData || []; + // Manual enrich for type matching + // TODO Consolidate in model updates + const manualEnrichedApplications = + updatedState?.data?.reportData.map( + (application): ApplicationHealthReportDetailEnriched => ({ + ...application, + isMarkedAsCritical: updatedStateApplicationData.some( + (a) => a.applicationName == application.applicationName && a.isCritical, + ), + }), + ) || []; + return from( + this.accessIntelligenceSecurityTasksService.assignTasks( + this.dialogParams.organizationId, + manualEnrichedApplications, + ), + ); + }), + ) + .subscribe({ + next: () => { + this.toastService.showToast({ + variant: "success", + title: this.i18nService.t("applicationReviewSaved"), + message: this.i18nService.t("newApplicationsReviewed"), + }); + this.saving.set(false); + this.handleAssigningCompleted(); + }, + error: (error: unknown) => { + this.logService.error( + "[NewApplicationsDialog] Failed to save application review or assign tasks", + error, + ); + this.saving.set(false); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorSavingReviewStatus"), + message: this.i18nService.t("pleaseTryAgain"), + }); + }, + }); + } + + /** + * Closes the dialog when the "Cancel" button is selected + */ + handleCancel() { + this.dialogRef.close(NewApplicationsDialogResultType.Close); + } + + /** + * Handles the tasksAssigned event from the embedded component. + * Closes the dialog with success indicator. + */ + protected handleAssigningCompleted = () => { + // Tasks were successfully assigned - close dialog + this.dialogRef.close(NewApplicationsDialogResultType.Complete); + }; + + /** + * Handles the back event from the embedded component. + * Returns to the select applications view. + */ + protected onBack = () => { + this.currentView.set(DialogView.SelectApplications); + }; +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html new file mode 100644 index 00000000000..15d8160a55d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html @@ -0,0 +1,83 @@ +
    + + +
    + + + + + + + + + + + + @for (app of filteredApplications(); track app.applicationName) { + + + + + + + + } + +
    + + + {{ "application" | i18n }} + + {{ "atRiskPasswords" | i18n }} + + {{ "totalPasswords" | i18n }} + + {{ "atRiskMembers" | i18n }} +
    + + +
    + + {{ app.applicationName }} +
    +
    + {{ app.atRiskPasswordCount }} + + {{ app.passwordCount }} + + {{ app.atRiskMemberCount }} +
    +
    +
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.ts new file mode 100644 index 00000000000..7a269d3aa15 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.ts @@ -0,0 +1,61 @@ +import { CommonModule } from "@angular/common"; +import { Component, input, output, ChangeDetectionStrategy, signal, computed } from "@angular/core"; +import { FormsModule } from "@angular/forms"; + +import { ApplicationHealthReportDetail } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { ButtonModule, DialogModule, SearchModule, TypographyModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "dirt-review-applications-view", + templateUrl: "./review-applications-view.component.html", + imports: [ + CommonModule, + ButtonModule, + DialogModule, + FormsModule, + SearchModule, + TypographyModule, + I18nPipe, + ], +}) +export class ReviewApplicationsViewComponent { + readonly applications = input.required(); + readonly selectedApplications = input.required>(); + + protected readonly searchText = signal(""); + + // Filter applications based on search text + protected readonly filteredApplications = computed(() => { + const search = this.searchText().toLowerCase(); + if (!search) { + return this.applications(); + } + return this.applications().filter((app) => app.applicationName.toLowerCase().includes(search)); + }); + + // Return the selected applications from the view + onToggleSelection = output(); + onToggleAll = output(); + + toggleSelection(applicationName: string): void { + this.onToggleSelection.emit(applicationName); + } + + toggleAll(): void { + this.onToggleAll.emit(); + } + + isAllSelected(): boolean { + const filtered = this.filteredApplications(); + return ( + filtered.length > 0 && + filtered.every((app) => this.selectedApplications().has(app.applicationName)) + ); + } + + onSearchTextChanged(searchText: string): void { + this.searchText.set(searchText); + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html deleted file mode 100644 index f7a5441030e..00000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html +++ /dev/null @@ -1,71 +0,0 @@ - - {{ "prioritizeCriticalApplications" | i18n }} -
    -
    - - - - - - - - - - @for (app of newApplications; track app) { - - - - - - } - -
    - {{ "application" | i18n }} - - {{ "atRiskItems" | i18n }} -
    - - -
    - - {{ app }} -
    -
    —
    -
    -
    - - - - -
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts deleted file mode 100644 index c9df3283fae..00000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { Component, inject } from "@angular/core"; -import { firstValueFrom } from "rxjs"; - -import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { - ButtonModule, - DialogModule, - DialogRef, - DialogService, - ToastService, - TypographyModule, -} from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; - -export interface NewApplicationsDialogData { - newApplications: string[]; -} - -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -@Component({ - templateUrl: "./new-applications-dialog.component.html", - imports: [CommonModule, ButtonModule, DialogModule, TypographyModule, I18nPipe], -}) -export class NewApplicationsDialogComponent { - protected newApplications: string[] = []; - protected selectedApplications: Set = new Set(); - - private dialogRef = inject(DialogRef); - private dataService = inject(RiskInsightsDataService); - private toastService = inject(ToastService); - private i18nService = inject(I18nService); - private logService = inject(LogService); - - /** - * Opens the new applications dialog - * @param dialogService The dialog service instance - * @param data Dialog data containing the list of new applications - * @returns Dialog reference - */ - static open(dialogService: DialogService, data: NewApplicationsDialogData) { - const ref = dialogService.open( - NewApplicationsDialogComponent, - { - data, - }, - ); - - // Set the component's data after opening - const instance = ref.componentInstance as NewApplicationsDialogComponent; - if (instance) { - instance.newApplications = data.newApplications; - } - - return ref; - } - - /** - * Toggles the selection state of an application. - * @param applicationName The application to toggle - */ - toggleSelection = (applicationName: string) => { - if (this.selectedApplications.has(applicationName)) { - this.selectedApplications.delete(applicationName); - } else { - this.selectedApplications.add(applicationName); - } - }; - - /** - * Checks if an application is currently selected. - * @param applicationName The application to check - * @returns True if selected, false otherwise - */ - isSelected = (applicationName: string): boolean => { - return this.selectedApplications.has(applicationName); - }; - - /** - * Handles the "Mark as Critical" button click. - * Saves review status for all new applications and marks selected ones as critical. - * Closes the dialog on success. - */ - onMarkAsCritical = async () => { - const selectedCriticalApps = Array.from(this.selectedApplications); - - try { - await firstValueFrom(this.dataService.saveApplicationReviewStatus(selectedCriticalApps)); - - this.toastService.showToast({ - variant: "success", - title: this.i18nService.t("applicationReviewSaved"), - message: - selectedCriticalApps.length > 0 - ? this.i18nService.t("applicationsMarkedAsCritical", selectedCriticalApps.length) - : this.i18nService.t("newApplicationsReviewed"), - }); - - // Close dialog with success indicator - this.dialogRef.close(true); - } catch { - this.logService.error("[NewApplicationsDialog] Failed to save review status"); - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorSavingReviewStatus"), - message: this.i18nService.t("pleaseTryAgain"), - }); - } - }; -} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts index b1cf43b2118..b4e2bf466b9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts @@ -91,6 +91,7 @@ export class AllApplicationsComponent implements OnInit { markAppsAsCritical = async () => { this.markingAsCritical = true; + const count = this.selectedUrls.size; this.dataService .saveCriticalApplications(Array.from(this.selectedUrls)) @@ -100,7 +101,7 @@ export class AllApplicationsComponent implements OnInit { this.toastService.showToast({ variant: "success", title: "", - message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"), + message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()), }); this.selectedUrls.clear(); this.markingAsCritical = false; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index b7d05c73768..15ccd3241e4 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -6,7 +6,8 @@ } @else { - @if (!(dataService.hasReportData$ | async)) { + @if (isRiskInsightsActivityTabFeatureEnabled && !(dataService.hasReportData$ | async)) { +
    @if (!hasCiphers) { @@ -33,47 +34,47 @@ }
    } @else { - +

    {{ "riskInsights" | i18n }}

    {{ "reviewAtRiskPasswords" | i18n }}
    - @if (dataLastUpdated) { -
    - + @let isRunningReport = dataService.isGeneratingReport$ | async; +
    + + @if (dataLastUpdated) { {{ "dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a") }} - @let isRunningReport = dataService.isGeneratingReport$ | async; - - - - - + } + + + + -
    - } + +
    diff --git a/libs/angular/src/billing/components/index.ts b/libs/angular/src/billing/components/index.ts index 34e1d27c1ed..574ed01e073 100644 --- a/libs/angular/src/billing/components/index.ts +++ b/libs/angular/src/billing/components/index.ts @@ -1 +1,2 @@ export * from "./premium.component"; +export * from "./premium-upgrade-dialog/premium-upgrade-dialog.component"; diff --git a/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts b/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts index e8a829d458d..8890584186d 100644 --- a/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts +++ b/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts @@ -10,7 +10,13 @@ import { BadgeModule } from "@bitwarden/components"; selector: "app-premium-badge", standalone: true, template: ` - `, @@ -21,7 +27,9 @@ export class PremiumBadgeComponent { constructor(private premiumUpgradePromptService: PremiumUpgradePromptService) {} - async promptForPremium() { + async promptForPremium(event: Event) { + event.stopPropagation(); + event.preventDefault(); await this.premiumUpgradePromptService.promptForPremium(this.organizationId()); } } diff --git a/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts b/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts index 08259358f30..bf50d16d3c4 100644 --- a/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts +++ b/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts @@ -5,18 +5,11 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessageSender } from "@bitwarden/common/platform/messaging"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { BadgeModule, I18nMockService } from "@bitwarden/components"; import { PremiumBadgeComponent } from "./premium-badge.component"; -class MockMessagingService implements MessageSender { - send = () => { - alert("Clicked on badge"); - }; -} - export default { title: "Billing/Premium Badge", component: PremiumBadgeComponent, @@ -40,12 +33,6 @@ export default { }); }, }, - { - provide: MessageSender, - useFactory: () => { - return new MockMessagingService(); - }, - }, { provide: BillingAccountProfileStateService, useValue: { diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html new file mode 100644 index 00000000000..99e1c173c2a --- /dev/null +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html @@ -0,0 +1,98 @@ +@if (cardDetails$ | async; as cardDetails) { +
    +
    + +
    +
    +
    +
    +

    + {{ "upgradeToPremium" | i18n }} +

    +
    + + +
    +

    + {{ cardDetails.tagline }} +

    +
    + + +
    +
    + {{ + cardDetails.price.amount | currency: "$" + }} + + / {{ cardDetails.price.cadence }} + +
    +
    + + +
    + +
    + + +
    + @if (cardDetails.features.length > 0) { +
      + @for (feature of cardDetails.features; track feature) { +
    • + + {{ + feature + }} +
    • + } +
    + } +
    +
    +
    +
    +} @else { + + {{ "loading" | i18n }} +} diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts new file mode 100644 index 00000000000..f2991cc41b4 --- /dev/null +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -0,0 +1,240 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { firstValueFrom, of, throwError } from "rxjs"; + +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { + EnvironmentService, + Region, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogRef, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; + +import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component"; + +describe("PremiumUpgradeDialogComponent", () => { + let component: PremiumUpgradeDialogComponent; + let fixture: ComponentFixture; + let mockDialogRef: jest.Mocked; + let mockSubscriptionPricingService: jest.Mocked; + let mockI18nService: jest.Mocked; + let mockToastService: jest.Mocked; + let mockEnvironmentService: jest.Mocked; + let mockPlatformUtilsService: jest.Mocked; + let mockLogService: jest.Mocked; + + const mockPremiumTier: PersonalSubscriptionPricingTier = { + id: PersonalSubscriptionPricingTierIds.Premium, + name: "Premium", + description: "Advanced features for power users", + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "standalone", + annualPrice: 10, + annualPricePerAdditionalStorageGB: 4, + features: [ + { key: "feature1", value: "Feature 1" }, + { key: "feature2", value: "Feature 2" }, + { key: "feature3", value: "Feature 3" }, + ], + }, + }; + + const mockFamiliesTier: PersonalSubscriptionPricingTier = { + id: PersonalSubscriptionPricingTierIds.Families, + name: "Families", + description: "Family plan", + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "packaged", + users: 6, + annualPrice: 40, + annualPricePerAdditionalStorageGB: 4, + features: [{ key: "featureA", value: "Feature A" }], + }, + }; + + beforeEach(async () => { + mockDialogRef = { + close: jest.fn(), + } as any; + + mockSubscriptionPricingService = { + getPersonalSubscriptionPricingTiers$: jest.fn(), + } as any; + + mockI18nService = { + t: jest.fn((key: string) => key), + } as any; + + mockToastService = { + showToast: jest.fn(), + } as any; + + mockEnvironmentService = { + environment$: of({ + getWebVaultUrl: () => "https://vault.bitwarden.com", + getRegion: () => Region.US, + }), + } as any; + + mockPlatformUtilsService = { + launchUri: jest.fn(), + } as any; + + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + of([mockPremiumTier, mockFamiliesTier]), + ); + + mockLogService = { + error: jest.fn(), + } as any; + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, PremiumUpgradeDialogComponent, CdkTrapFocus], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, + { provide: I18nService, useValue: mockI18nService }, + { provide: ToastService, useValue: mockToastService }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: LogService, useValue: mockLogService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PremiumUpgradeDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should emit cardDetails$ observable with Premium tier data", async () => { + const cardDetails = await firstValueFrom(component["cardDetails$"]); + + expect(mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$).toHaveBeenCalled(); + expect(cardDetails).toBeDefined(); + expect(cardDetails?.title).toBe("Premium"); + }); + + it("should filter to Premium tier only", async () => { + const cardDetails = await firstValueFrom(component["cardDetails$"]); + + expect(cardDetails?.title).toBe("Premium"); + expect(cardDetails?.title).not.toBe("Families"); + }); + + it("should map Premium tier to card details correctly", async () => { + const cardDetails = await firstValueFrom(component["cardDetails$"]); + + expect(cardDetails?.title).toBe("Premium"); + expect(cardDetails?.tagline).toBe("Advanced features for power users"); + expect(cardDetails?.price.amount).toBe(10 / 12); + expect(cardDetails?.price.cadence).toBe("monthly"); + expect(cardDetails?.button.text).toBe("upgradeNow"); + expect(cardDetails?.button.type).toBe("primary"); + expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2", "Feature 3"]); + }); + + it("should use i18nService for button text", async () => { + const cardDetails = await firstValueFrom(component["cardDetails$"]); + + expect(mockI18nService.t).toHaveBeenCalledWith("upgradeNow"); + expect(cardDetails?.button.text).toBe("upgradeNow"); + }); + + describe("upgrade()", () => { + it("should launch URI with query parameter for cloud-hosted environments", async () => { + mockEnvironmentService.environment$ = of({ + getWebVaultUrl: () => "https://vault.bitwarden.com", + getRegion: () => Region.US, + } as any); + + await component["upgrade"](); + + expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( + "https://vault.bitwarden.com/#/settings/subscription/premium?callToAction=upgradeToPremium", + ); + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + it("should launch URI without query parameter for self-hosted environments", async () => { + mockEnvironmentService.environment$ = of({ + getWebVaultUrl: () => "https://self-hosted.example.com", + getRegion: () => Region.SelfHosted, + } as any); + + await component["upgrade"](); + + expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( + "https://self-hosted.example.com/#/settings/subscription/premium", + ); + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + it("should launch URI with query parameter for EU cloud region", async () => { + mockEnvironmentService.environment$ = of({ + getWebVaultUrl: () => "https://vault.bitwarden.eu", + getRegion: () => Region.EU, + } as any); + + await component["upgrade"](); + + expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( + "https://vault.bitwarden.eu/#/settings/subscription/premium?callToAction=upgradeToPremium", + ); + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + }); + + it("should close dialog when close button clicked", () => { + component["close"](); + + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + describe("error handling", () => { + it("should show error toast and return EMPTY and close dialog when getPersonalSubscriptionPricingTiers$ throws an error", (done) => { + const error = new Error("Service error"); + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + throwError(() => error), + ); + + const errorFixture = TestBed.createComponent(PremiumUpgradeDialogComponent); + const errorComponent = errorFixture.componentInstance; + errorFixture.detectChanges(); + + const cardDetails$ = errorComponent["cardDetails$"]; + + cardDetails$.subscribe({ + next: () => { + done.fail("Observable should not emit any values"); + }, + complete: () => { + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "error", + message: "unexpectedError", + }); + expect(mockDialogRef.close).toHaveBeenCalled(); + done(); + }, + error: (err: unknown) => done.fail(`Observable should not error: ${err}`), + }); + }); + }); +}); diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts new file mode 100644 index 00000000000..7ba09192d3c --- /dev/null +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts @@ -0,0 +1,117 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { of } from "rxjs"; + +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + ButtonModule, + DialogModule, + DialogRef, + ToastOptions, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component"; + +const mockPremiumTier: PersonalSubscriptionPricingTier = { + id: PersonalSubscriptionPricingTierIds.Premium, + name: "Premium", + description: "Complete online security", + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "standalone", + annualPrice: 10, + annualPricePerAdditionalStorageGB: 4, + features: [ + { key: "builtInAuthenticator", value: "Built-in authenticator" }, + { key: "secureFileStorage", value: "Secure file storage" }, + { key: "emergencyAccess", value: "Emergency access" }, + { key: "breachMonitoring", value: "Breach monitoring" }, + { key: "andMoreFeatures", value: "And more!" }, + ], + }, +}; + +export default { + title: "Billing/Premium Upgrade Dialog", + component: PremiumUpgradeDialogComponent, + description: "A dialog for upgrading to Premium subscription", + decorators: [ + moduleMetadata({ + imports: [DialogModule, ButtonModule, TypographyModule], + providers: [ + { + provide: DialogRef, + useValue: { + close: () => {}, + }, + }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: { + getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTier]), + }, + }, + { + provide: ToastService, + useValue: { + showToast: (options: ToastOptions) => {}, + }, + }, + { + provide: EnvironmentService, + useValue: { + cloudWebVaultUrl$: of("https://vault.bitwarden.com"), + }, + }, + { + provide: PlatformUtilsService, + useValue: { + launchUri: (uri: string) => {}, + }, + }, + { + provide: I18nService, + useValue: { + t: (key: string) => { + switch (key) { + case "upgradeNow": + return "Upgrade Now"; + case "month": + return "month"; + case "upgradeToPremium": + return "Upgrade To Premium"; + default: + return key; + } + }, + }, + }, + { + provide: LogService, + useValue: { + error: {}, + }, + }, + ], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium---business-upgrade-flows?node-id=931-17785&t=xOhvwjYLpjoMPgND-1", + }, + }, +} as Meta; + +type Story = StoryObj; +export const Default: Story = {}; diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts new file mode 100644 index 00000000000..d20c0d668c4 --- /dev/null +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts @@ -0,0 +1,123 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, + SubscriptionCadence, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { + EnvironmentService, + Region, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + ButtonModule, + ButtonType, + DialogModule, + DialogRef, + DialogService, + IconButtonModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; + +type CardDetails = { + title: string; + tagline: string; + price: { amount: number; cadence: SubscriptionCadence }; + button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } }; + features: string[]; +}; + +@Component({ + selector: "billing-premium-upgrade-dialog", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + DialogModule, + ButtonModule, + IconButtonModule, + TypographyModule, + CdkTrapFocus, + JslibModule, + ], + templateUrl: "./premium-upgrade-dialog.component.html", +}) +export class PremiumUpgradeDialogComponent { + protected cardDetails$: Observable = this.subscriptionPricingService + .getPersonalSubscriptionPricingTiers$() + .pipe( + map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)), + map((tier) => this.mapPremiumTierToCardDetails(tier!)), + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("error"), + message: this.i18nService.t("unexpectedError"), + }); + this.logService.error("Error fetching and mapping pricing tiers", error); + this.dialogRef.close(); + return EMPTY; + }), + ); + + constructor( + private dialogRef: DialogRef, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, + private i18nService: I18nService, + private toastService: ToastService, + private environmentService: EnvironmentService, + private platformUtilsService: PlatformUtilsService, + private logService: LogService, + ) {} + + protected async upgrade(): Promise { + const environment = await firstValueFrom(this.environmentService.environment$); + let vaultUrl = environment.getWebVaultUrl() + "/#/settings/subscription/premium"; + if (environment.getRegion() !== Region.SelfHosted) { + vaultUrl += "?callToAction=upgradeToPremium"; + } + this.platformUtilsService.launchUri(vaultUrl); + this.dialogRef.close(); + } + + protected close(): void { + this.dialogRef.close(); + } + + private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails { + return { + title: tier.name, + tagline: tier.description, + price: { + amount: tier.passwordManager.annualPrice / 12, + cadence: SubscriptionCadenceIds.Monthly, + }, + button: { + text: this.i18nService.t("upgradeNow"), + type: "primary", + icon: { type: "bwi-external-link", position: "after" }, + }, + features: tier.passwordManager.features.map((f) => f.value), + }; + } + + /** + * Opens the premium upgrade dialog. + * + * @param dialogService - The dialog service used to open the component + * @returns A dialog reference object + */ + static open(dialogService: DialogService): DialogRef { + return dialogService.open(PremiumUpgradeDialogComponent); + } +} diff --git a/libs/angular/src/billing/directives/not-premium.directive.ts b/libs/angular/src/billing/directives/not-premium.directive.ts index 41d62bb773e..8582a9f4396 100644 --- a/libs/angular/src/billing/directives/not-premium.directive.ts +++ b/libs/angular/src/billing/directives/not-premium.directive.ts @@ -1,4 +1,5 @@ -import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; +import { DestroyRef, Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -16,6 +17,7 @@ export class NotPremiumDirective implements OnInit { private templateRef: TemplateRef, private viewContainer: ViewContainerRef, private billingAccountProfileStateService: BillingAccountProfileStateService, + private destroyRef: DestroyRef, private accountService: AccountService, ) {} @@ -27,14 +29,15 @@ export class NotPremiumDirective implements OnInit { return; } - const premium = await firstValueFrom( - this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), - ); - - if (premium) { - this.viewContainer.clear(); - } else { - this.viewContainer.createEmbeddedView(this.templateRef); - } + this.billingAccountProfileStateService + .hasPremiumFromAnySource$(account.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((premium) => { + if (premium) { + this.viewContainer.clear(); + } else { + this.viewContainer.createEmbeddedView(this.templateRef); + } + }); } } diff --git a/libs/angular/src/billing/services/premium-interest/noop-premium-interest-state.service.ts b/libs/angular/src/billing/services/premium-interest/noop-premium-interest-state.service.ts new file mode 100644 index 00000000000..f941e86e0d0 --- /dev/null +++ b/libs/angular/src/billing/services/premium-interest/noop-premium-interest-state.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from "@angular/core"; + +import { UserId } from "@bitwarden/user-core"; + +import { PremiumInterestStateService } from "./premium-interest-state.service.abstraction"; + +@Injectable() +export class NoopPremiumInterestStateService implements PremiumInterestStateService { + async getPremiumInterest(userId: UserId): Promise { + return null; + } // no-op + async setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise {} // no-op + async clearPremiumInterest(userId: UserId): Promise {} // no-op +} diff --git a/libs/angular/src/billing/services/premium-interest/premium-interest-state.service.abstraction.ts b/libs/angular/src/billing/services/premium-interest/premium-interest-state.service.abstraction.ts new file mode 100644 index 00000000000..850560df38c --- /dev/null +++ b/libs/angular/src/billing/services/premium-interest/premium-interest-state.service.abstraction.ts @@ -0,0 +1,14 @@ +import { UserId } from "@bitwarden/user-core"; + +/** + * A service that manages state which conveys whether or not a user has expressed interest + * in setting up a premium subscription. This applies for users who began the registration + * process on https://bitwarden.com/go/start-premium/, which is a marketing page designed + * to streamline users who intend to setup a premium subscription after registration. + * - Implemented in Web only. No-op for other clients. + */ +export abstract class PremiumInterestStateService { + abstract getPremiumInterest(userId: UserId): Promise; + abstract setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise; + abstract clearPremiumInterest(userId: UserId): Promise; +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c3d8c31298f..1593abb90fd 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -151,6 +151,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; @@ -158,6 +159,7 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service"; import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; +import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service"; import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service"; import { DefaultKeyGenerationService, @@ -376,6 +378,8 @@ import { DefaultSetInitialPasswordService } from "../auth/password-management/se import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction"; import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation"; +import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service"; +import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; import { DocumentLangSetter } from "../platform/i18n"; import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; @@ -1459,6 +1463,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultBillingAccountProfileStateService, deps: [StateProvider, PlatformUtilsServiceAbstraction, ApiServiceAbstraction], }), + safeProvider({ + provide: SubscriptionPricingServiceAbstraction, + useClass: DefaultSubscriptionPricingService, + deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService], + }), safeProvider({ provide: OrganizationManagementPreferencesService, useClass: DefaultOrganizationManagementPreferencesService, @@ -1716,6 +1725,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultNewDeviceVerificationComponentService, deps: [], }), + safeProvider({ + provide: PremiumInterestStateService, + useClass: NoopPremiumInterestStateService, + deps: [], + }), ]; @NgModule({ diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index 1680182f9de..e03162c2d91 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -11,6 +11,7 @@ import { BehaviorSubject, concatMap, switchMap, + tap, } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -33,6 +34,7 @@ import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { DialogService, ToastService } from "@bitwarden/components"; // Value = hours @@ -144,6 +146,7 @@ export class AddEditComponent implements OnInit, OnDestroy { protected billingAccountProfileStateService: BillingAccountProfileStateService, protected accountService: AccountService, protected toastService: ToastService, + protected premiumUpgradePromptService: PremiumUpgradePromptService, ) { this.typeOptions = [ { name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true }, @@ -192,10 +195,15 @@ export class AddEditComponent implements OnInit, OnDestroy { } }); - this.formGroup.controls.type.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((val) => { - this.type = val; - this.typeChanged(); - }); + this.formGroup.controls.type.valueChanges + .pipe( + tap((val) => { + this.type = val; + }), + switchMap(() => this.typeChanged()), + takeUntil(this.destroy$), + ) + .subscribe(); this.formGroup.controls.selectedDeletionDatePreset.valueChanges .pipe(takeUntil(this.destroy$)) @@ -426,11 +434,11 @@ export class AddEditComponent implements OnInit, OnDestroy { return false; } - typeChanged() { + async typeChanged() { if (this.type === SendType.File && !this.alertShown) { if (!this.canAccessPremium) { this.alertShown = true; - this.messagingService.send("premiumRequired"); + await this.premiumUpgradePromptService.promptForPremium(); } else if (!this.emailVerified) { this.alertShown = true; this.messagingService.send("emailVerificationRequired"); diff --git a/libs/auth/src/angular/login/login.component.html b/libs/auth/src/angular/login/login.component.html index 4e1689b1054..9faa582c071 100644 --- a/libs/auth/src/angular/login/login.component.html +++ b/libs/auth/src/angular/login/login.component.html @@ -21,7 +21,7 @@ bitInput appAutofocus (input)="onEmailInput($event)" - (keyup.enter)="continuePressed()" + (keyup.enter)="ssoRequired ? handleSsoClick() : continuePressed()" /> diff --git a/libs/auth/src/common/login-strategies/README.md b/libs/auth/src/common/login-strategies/README.md new file mode 100644 index 00000000000..69d57f97467 --- /dev/null +++ b/libs/auth/src/common/login-strategies/README.md @@ -0,0 +1,377 @@ +# Overview of Authentication at Bitwarden + +> **Table of Contents** +> +> - [Authentication Methods](#authentication-methods) +> - [The Login Credentials Object](#the-login-credentials-object) +> - [The `LoginStrategyService` and our Login Strategies](#the-loginstrategyservice-and-our-login-strategies) +> - [The `logIn()` and `startLogIn()` Methods](#the-login-and-startlogin-methods) +> - [Handling the `AuthResult`](#handling-the-authresult) +> - [Diagram of Authentication Flows](#diagram-of-authentication-flows) + +
    + +## Authentication Methods + +Bitwarden provides 5 methods for logging in to Bitwarden, as defined in our [`AuthenticationType`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/enums/authentication-type.ts) enum. They are: + +1. [Login with Master Password](https://bitwarden.com/help/bitwarden-security-white-paper/#authentication-and-decryption) +2. [Login with Auth Request](https://bitwarden.com/help/log-in-with-device/) (aka Login with Device) — authenticate with a one-time access code +3. [Login with Single Sign-On](https://bitwarden.com/help/about-sso/) — authenticate with an SSO Identity Provider (IdP) through SAML or OpenID Connect (OIDC) +4. [Login with Passkey](https://bitwarden.com/help/login-with-passkeys/) (WebAuthn) +5. [Login with User API Key](https://bitwarden.com/help/personal-api-key/) — authenticate with an API key and secret + +
    + +**Login Initiation** + +_Angular Clients - Initiating Components_ + +A user begins the login process by entering their email on the `/login` screen (`LoginComponent`). From there, the user must click one of the following buttons to initiate a login method by navigating to that method's associated "initiating component": + +- `"Continue"` → user stays on the `LoginComponent` and enters a Master Password +- `"Log in with device"` → navigates user to `LoginViaAuthRequestComponent` +- `"Use single sign-on"` → navigates user to `SsoComponent` +- `"Log in with passkey"` → navigates user to `LoginViaWebAuthnComponent` + - Note: Login with Passkey is currently not available on the Desktop client. + +> [!NOTE] +> +> - Our Angular clients do not support the Login with User API Key method. +>   +> - The Login with Master Password method is also used by the +> `RegistrationFinishComponent` and `CompleteTrialInitiationComponent` (the user automatically +> gets logged in with their Master Password after registration), as well as the `RecoverTwoFactorComponent` +> (the user logs in with their Master Password along with their 2FA recovery code). + +
    + +_CLI Client - `LoginCommand`_ + +The CLI client supports the following login methods via the `LoginCommand`: + +- Login with Master Password +- Login with Single Sign-On +- Login with User API Key (which can _only_ be initiated from the CLI client) + +
    + +> [!IMPORTANT] +> While each authentication method has its own unique logic, this document discusses the +> logic that is _generally_ common to all authentication methods. It provides a high-level +> overview of authentication and as such will involve some abstraction and generalization. + +
    + +## The Login Credentials Object + +When the user presses the "submit" action on an initiating component (or via `LoginCommand` for CLI), we build a **login credentials object**, which contains the core credentials needed to initiate the specific login method. + +For example, when the user clicks "Log in with master password" on the `LoginComponent`, we build a `PasswordLoginCredentials` object, which is defined as: + +```typescript +export class PasswordLoginCredentials { + readonly type = AuthenticationType.Password; + + constructor( + public email: string, + public masterPassword: string, + public twoFactor?: TokenTwoFactorRequest, + public masterPasswordPoliciesFromOrgInvite?: MasterPasswordPolicyOptions, + ) {} +} +``` + +Notice that the `type` is automatically set to `AuthenticationType.Password`, and the `PasswordLoginCredentials` object simply requires an `email` and `masterPassword` to initiate the login method. + +Each authentication method builds its own type of credentials object. These are defined in [`login-credentials.ts`](https://github.com/bitwarden/clients/blob/main/libs/auth/src/common/models/domain/login-credentials.ts). + +- `PasswordLoginCredentials` +- `AuthRequestLoginCredentials` +- `SsoLoginCredentials` +- `WebAuthnLoginCredentials` +- `UserApiLoginCredentials` + +After building the credentials object, we then call the `logIn()` method on the `LoginStrategyService`, passing in the credentials object as an argument: `LoginStrategyService.logIn(credentials)` + +
    + +## The `LoginStrategyService` and our Login Strategies + +The [`LoginStrategyService`](https://github.com/bitwarden/clients/blob/main/libs/auth/src/common/services/login-strategies/login-strategy.service.ts) acts as an orchestrator that determines which of our specific **login strategies** should be initialized and used for the login process. + +> [!IMPORTANT] +> Our authentication methods are handled by different [login strategies](https://github.com/bitwarden/clients/tree/main/libs/auth/src/common/login-strategies), making use of the [Strategy Design Pattern](https://refactoring.guru/design-patterns/strategy). Those strategies are: +> +> - `PasswordLoginStrategy` +> - `AuthRequestLoginStrategy` +> - `SsoLoginStrategy` +> - `WebAuthnLoginStrategy` +> - `UserApiLoginStrategy` +> +> Each of those strategies extend the base [`LoginStrategy`](https://github.com/bitwarden/clients/blob/main/libs/auth/src/common/login-strategies/login.strategy.ts), which houses common login logic. + +More specifically, within its `logIn()` method, the `LoginStrategyService` uses the `type` property on the credentials object to determine which specific login strategy to initialize. + +For example, the `PasswordLoginCredentials` object has `type` of `AuthenticationType.Password`. This tells the `LoginStrategyService` to initialize and use the `PasswordLoginStrategy` for the login process. + +Once the `LoginStrategyService` initializes the appropriate strategy, it then calls the `logIn()` method defined on _that_ particular strategy, passing on the credentials object as an argument. For example: `PasswordLoginStrategy.logIn(credentials)` + +
    + +To summarize everything so far: + +```bash +Initiating Component (Submit Action) # ex: LoginComponent.submit() + | + Build credentials object # ex: PasswordLoginCredentials + | + Call LoginStrategyService.logIn(credentials) + | + Initialize specific strategy # ex: PasswordLoginStrategy + | + Call strategy.logIn(credentials) # ex: PasswordLoginStrategy.logIn(credentials) + + ... +``` + +
    + +## The `logIn()` and `startLogIn()` Methods + +Each login strategy has its own unique implementation of the `logIn()` method, but each `logIn()` method performs the following general logic with the help of the credentials object: + +1. Build a `LoginStrategyData` object with a `TokenRequest` property +2. Cache the `LoginStrategyData` object +3. Call the `startLogIn()` method on the base `LoginStrategy` + +Here are those steps in more detail: + +1. **Build a `LoginStrategyData` object with a `TokenRequest` property** + + Each strategy uses the credentials object to help build a type of `LoginStrategyData` object, which contains the data needed throughout the lifetime of the particular strategy, and must, at minimum, contain a `tokenRequest` property (more on this below). + + ```typescript + export abstract class LoginStrategyData { + tokenRequest: + | PasswordTokenRequest + | SsoTokenRequest + | WebAuthnLoginTokenRequest + | UserApiTokenRequest + | undefined; + + abstract userEnteredEmail?: string; + } + ``` + + Each strategy has its own class that implements the `LoginStrategyData` interface: + - `PasswordLoginStrategyData` + - `AuthRequestLoginStrategyData` + - `SsoLoginStrategyData` + - `WebAuthnLoginStrategyData` + - `UserApiLoginStrategyData` + + So in our ongoing example that uses the "Login with Master Password" method, the call to `PasswordLoginStrategy.logIn(PasswordLoginCredentials)` would build a `PasswordLoginStrategyData` object that contains the data needed throughout the lifetime of the `PasswordLoginStrategy`. + + That `PasswordLoginStrategyData` object is defined as: + + ```typescript + export class PasswordLoginStrategyData implements LoginStrategyData { + tokenRequest: PasswordTokenRequest; + + userEnteredEmail: string; + localMasterKeyHash: string; + masterKey: MasterKey; + forcePasswordResetReason: ForceSetPasswordReason = ForceSetPasswordReason.None; + } + ``` + + Each of the `LoginStrategyData` types have varying properties, but one property common to all is the `tokenRequest` property. + + The `tokenRequest` property holds some type of [`TokenRequest`](https://github.com/bitwarden/clients/tree/main/libs/common/src/auth/models/request/identity-token) object based on the strategy: + - `PasswordTokenRequest` — used by both `PasswordLoginStrategy` and `AuthRequestLoginStrategy` + - `SsoTokenRequest` + - `WebAuthnLoginTokenRequest` + - `UserApiTokenRequest` + + This `TokenRequest` object is _also_ built within the `logIn()` method and gets added to the `LoginStrategyData` object as the `tokenRequest` property. + +
    + +2. **Cache the `LoginStrategyData` object** + + Because a login attempt could "fail" due to a need for Two Factor Authentication (2FA) or New Device Verification (NDV), we need to preserve the `LoginStrategyData` so that we can re-use it later when the user provides their 2FA or NDV token. This way, the user does not need to completely re-enter all of their credentials. + + The way we cache this `LoginStrategyData` is simply by saving it to a property called `cache` on the strategy. There will be more details on how this cache is used later on. + +
    + +3. **Call the `startLogIn()` method on the base `LoginStrategy`** + + Next, we call the `startLogIn()` method, which exists on the base `LoginStrategy` and is therefore common to all login strategies. The `startLogIn()` method does the following: + 1. **Makes a `POST` request to the `/connect/token` endpoint on our Identity Server** + - `REQUEST` + + The exact payload for this request is determined by the `TokenRequest` object. More specifically, the base `TokenRequest` class contains a `toIdentityToken()` method which gets overridden/extended by the sub-classes (`PasswordTokenRequest.toIdentityToken()`, etc.). This `toIdentityToken()` method produces the exact payload that gets sent to our `/connect/token` endpoint. + + The payload includes OAuth2 parameters, such as `scope`, `client_id`, and `grant_type`, as well as any other credentials that the server needs to complete validation for the specific authentication method. + + - `RESPONSE` + + The Identity Server validates the request and then generates some type of `IdentityResponse`, which can be one of three types: + - [`IdentityTokenResponse`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/models/response/identity-token.response.ts) + - Meaning: the user has been authenticated + - Response Contains: + - Authentication information, such as: + - An access token (which is a JWT with claims about the user) + - A refresh token + - Decryption information, such as: + - The user's master-key-encrypted user key (if the user has a master password), along with their KDF settings + - The user's user-key-encrypted private key + - A `userDecryptionOptions` object that contains information about which decryption options the user has available to them + - A flag that indicates if the user is required to set or change their master password + - Any master password policies the user is required to adhere to + + - [`IdentityTwoFactorResponse`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/models/response/identity-two-factor.response.ts) + - Meaning: the user needs to complete Two Factor Authentication + - Response Contains: + - A list of which 2FA providers the user has configured + - Any master password policies the user is required to adhere to + + - [`IdentityDeviceVerificationResponse`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/models/response/identity-device-verification.response.ts) + - Meaning: the user needs to verify their new device via [new device verification](https://bitwarden.com/help/new-device-verification/) + - Response Contains: a simple boolean property that states whether or not the device has been verified + + 2. **Calls one of the `process[IdentityType]Response()` methods** + + Each of these methods builds and returns an [`AuthResult`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/models/domain/auth-result.ts) object, which gets used later to determine how to direct the user after an authentication attempt. + + The specific method that gets called depends on the type of the `IdentityResponse`: + - If `IdentityTokenResponse` → call `processTokenResponse()` + - Instantiates a new `AuthResult` object + - Calls `saveAccountInformation()` to initialize the account with information from the `IdentityTokenResponse` + - Decodes the access token (a JWT) to get information about the user (userId, email, etc.) + - Sets several things to state: + - The account (via `AccountService`) + - The user's environment + - `userDecryptionOptions` + - `masterPasswordUnlockData` (_if_ `userDecryptionOptions` allows for master password unlock): + - Salt + - KDF config + - Master-key-encrypted user key + - Access token and refresh token + - KDF config + - Premium status + - If the `IdentityTokenResponse` contains a `twoFactorToken` (because the user previously selected "remember me" for their 2FA method), set that token to state + - Sets cryptographic properties to state: master key, user key, private key + - Sets a `forceSetPasswordReason` to state (if necessary) + - Returns the `AuthResult` + + - If `IdentityTwoFactorResponse` → call `processTwoFactorResponse()` + - Instantiates a new `AuthResult` object + - Sets `AuthResult.twoFactorProviders` to the list of 2FA providers from the `IdentityTwoFactorResponse` + - Sets that same list of of 2FA providers to global state (memory) + - Returns the `AuthResult` + + - If `IdentityDeviceVerificationResponse` → call `processDeviceVerificationResponse()` + - Instantiates a new `AuthResult` object + - Sets `AuthResult.requiresDeviceVerification` to `true` + - Returns the `AuthResult` + +
    + +## Handling the `AuthResult` + +The `AuthResult` object that gets returned from the `process[IdentityType]Response()` method ultimately gets returned up through the chain of callers until it makes its way back to the initiating component (ex: the `LoginComponent` for Login with Master Password). + +The initiating component will then use the information on that `AuthResult` to determine how to direct the user after an authentication attempt. + +Below is a high-level overview of how the `AuthResult` is handled, but note again that there are abstractions in this diagram — it doesn't depict every edge case, and is just meant to give a general picture. + +```bash +Initiating Component (Submit Action) < - - - + | \ + LoginStrategyService.logIn() - \ + | \ # AuthResult bubbles back up + strategy.logIn() - \ # through chain of callers + | \ # to the initiating component + startLogIn() - \ + | \ + process[IdentityType]Response() - \ + | \ + returns AuthResult - - - - - - - - + + | + - - - - - - - - - - # Initiating component then + | # uses the AuthResult in + handleAuthResult(authResult) # handleAuthResult() + | + IF AuthResult.requiresTwoFactor + | # route user to /2fa to complete 2FA + | + IF AuthResult.requiresDeviceVerification + | # route user to /device-verification to complete NDV + | + # Otherwise, route user to /vault +``` + +
    + +Now for a more detailed breakdown of how the `AuthResult` is handled... + +There are two broad types of scenarios that the user will fall into: + +1. Re-submit scenarios +2. Successful Authentication scenarios + +### Re-submit Scenarios + +There are two cases where a user is required to provide additional information before they can be authenticated: Two Factor Authentication (2FA) and New Device Verification (NDV). In these scenarios, we actually need the user to "re-submit" their original request, along with their added 2FA or NDV token. But remember earlier that we cached the `LoginStrategyData`. This makes it so the user does not need to re-enter their original credentials. Instead, the user simply provides their 2FA or NDV token, we add it to their original (cached) `LoginStrategyData`, and then we re-submit the request. + +Here is how these scenarios work: + +**User must complete Two Factor Authentication** + +1. Remember that when the server response is `IdentityTwoFactorResponse`, we set 2FA provider data into state, and also set `requiresTwoFactor` to `true` on the `AuthResult`. +2. When `AuthResult.requiresTwoFactor` is `true`, the specific login strategy exports its `LoginStrategyData` to the `LoginStrategyService`, where it gets stored in memory. This means the `LoginStrategyService` has a cache of the original request the user sent. +3. We route the user to `/2fa` (`TwoFactorAuthComponent`). +4. The user enters their 2FA token. +5. On submission, the `LoginStrategyService` calls `logInTwoFactor()` on the particular login strategy. This method then: + - Takes the cached `LoginStrategyData` (the user's original request), and appends the 2FA token onto the `TokenRequest` + - Calls `startLogIn()` again, this time using the updated `LoginStrategyData` that includes the 2FA token. + +**User must complete New Device Verification** + +Note that we currently only require new device verification on Master Password logins (`PasswordLoginStrategy`) for users who do not have a 2FA method setup. + +1. Remember that when the server response is `IdentityDeviceVerificationResponse`, we set `requiresDeviceVerification` to `true` on the `AuthResult`. +2. When `AuthResult.requiresDeviceVerification` is `true`, the specific login strategy exports its `LoginStrategyData` to the `LoginStrategyService`, where it gets stored in memory. This means the `LoginStrategyService` has a cache of the original request the user sent. +3. We route the user to `/device-verification`. +4. The user enters their NDV token. +5. On submission, the `LoginStrategyService` calls `logInNewDeviceVerification()` on the particular login strategy. This method then: + - Takes the cached `LoginStrategyData` (the user's original request), and appends the NDV token onto the `TokenRequest`. + - Calls `startLogIn()` again, this time using the updated `LoginStrategyData` that includes the NDV token. + +### Successful Authentication Scenarios + +**User must change their password** + +A user can be successfully authenticated but still required to set/change their master password. In this case, the user gets routed to the relevant set/change password component (`SetInitialPassword` or `ChangePassword`). + +**User does not need to complete 2FA, NDV, or set/change their master password** + +In this case, the user proceeds to their `/vault`. + +**Trusted Device Encryption scenario** + +If the user is on an untrusted device, they get routed to `/login-initiated` to select a decryption option. If the user is on a trusted device, they get routed to `/vault` because decryption can be done automatically. + +
    + +## Diagram of Authentication Flows + +Here is a high-level overview of what all of this looks like in the end. + +
    + +![A Diagram of our Authentication Flows](./overview-of-authentication.svg) diff --git a/libs/auth/src/common/login-strategies/overview-of-authentication.svg b/libs/auth/src/common/login-strategies/overview-of-authentication.svg new file mode 100644 index 00000000000..1c846325c67 --- /dev/null +++ b/libs/auth/src/common/login-strategies/overview-of-authentication.svg @@ -0,0 +1,4 @@ + + +Login with Master PasswordLogin with Auth RequestLogin with Single Sign-OnLogin with PasskeyLogin with User API Key1. User enters email2. User clicks a button on the LoginComponent to take them to an "initiating component" to initiate a particular login methodPasswordLoginCredentialsAuthRequestLoginCredentialsSsoLoginCredentialsUserApiLoginCredentialsWebAuthnLoginCredentialsLoginStrategyServicelogIn(credentials)logInTwoFactor(...)logInNewDeviceVerification(...)LoginStrategyData CacheIf 2FA or NDV are required, this cache retains the LoginStrategyDatafor use in the re-submission. Clears on successful login.1. Initializes the specific strategy2. Calls logIn(...) on the strategyAll login methods start by calling LoginStrategyService.logIn(credentials)Calls logInTwoFactor(...) on the strategyCalls logInNewDeviceVerification(...)on the strategyLoginStrategystartLogin()POST identity token to /connect/tokenServer validates the request and sends a responseprocessDeviceVerificationResponse()processTwoFactorResponse()Response #1:IdentityTokenResponseprocessTokenResponse()AuthResult- Sets authentication and decryption data to state- Sets 2FA providers to state- Sets AuthResult.twoFactorProviders to true- Sets AuthResult.requiresDeviceVerification to trueCall the relevant method on the specific strategy:logIn(), logInTwoFactor(), or logInNewDeviceVerification()PasswordLoginStrategyPasswordLoginStrategyData { tokenRequest: PasswordTokenRequest, // ... other properties}logIn(...)logInTwoFactor(...)logInNewDeviceVerification(...)AuthRequestLoginStrategyAuthRequestLoginStrategyData { tokenRequest: PasswordTokenRequest, // ... other properties}logIn(...)logInTwoFactor(...)SsoLoginStrategySsoLoginStrategyData { tokenRequest: SsoTokenRequest, // ... other properties}logIn(...)logInTwoFactor(...)WebAuthnLoginStrategyWebAuthnLoginStrategyData { tokenRequest: WebAuthnTokenRequest, // ... other properties}logIn(...)logInTwoFactor(...)UserApiLoginStrategyUserApiLoginStrategyData { tokenRequest: UserApiTokenRequest, // ... other properties}logIn(...)logInTwoFactor(...)TwoFactorAuthComponent(/2fa)ORNewDeviceVerificationComponent(/device-verification)Route to...Route to...Route to...VaultComponent/vaultSetInitialPasswordComponent(/set-initial-password)ORChangePasswordComponent(/change-password)Does AuthResultrequire 2FA or NDV?NOYESYESThe LoginStrategyService collects the LoginStrategyData bycalling exportCache() on the specific strategy.The LoginStrategyService saves that exportedLoginStrategyData to its own state cache.This way, the LoginStrategyData can be re-submitted whenthe user attempts 2FA or NDV.User submits their2FA or NDV tokenThe logIn(), logInTwoFactor(), and logInNewDeviceVerification() methodsall ultimately call startLogin() on the base LoginStrategyCall loginTwoFactor(...) or logInNewDeviceVerification(...)Save exported LoginStrategyData to cache Is user requiredto set/changemaster password?NONote:The logInTwoFactor() method actuallyexists on the base LoginStrategy.Some login strategies have a slightoverride/extension of the method.For the sake of simplicity, thisdiagram shows the logInTwoFactor()method as existing on each strategy.exportCache()exportCache()exportCache()exportCache()exportCache()LoginComponent/login"Continue""Log in with device""Use single sign-on""Log in with passkey"LoginComponent/loginLoginViaAuthRequestComponent/login-with-deviceSsoComponent/ssoLoginViaWebAuthnComponent/login-with-passkeyLoginCommand(only available on CLI)Session TimeoutIf 2FA or NDV are required, start a timer. If user does not completeauthentication within 5 minutes, the LoginStrategyData Cache willclear and they will have to restart the authentication process.PasswordLoginStrategyData { tokenRequest: PasswordTokenRequest, // ... other properties}Example:PasswordLoginStrategy.exportCache()Response #2:IdentityTwoFactorResponseResponse #3:IdentityDeviceVerificationResponseUser isconsideredauthenticatedat this point \ No newline at end of file diff --git a/libs/common/src/abstractions/audit.service.ts b/libs/common/src/abstractions/audit.service.ts index a00b2bf038a..71edc3f1740 100644 --- a/libs/common/src/abstractions/audit.service.ts +++ b/libs/common/src/abstractions/audit.service.ts @@ -14,10 +14,4 @@ export abstract class AuditService { * @returns A promise that resolves to an array of BreachAccountResponse objects. */ abstract breachedAccounts: (username: string) => Promise; - /** - * Checks if a domain is known for phishing. - * @param domain The domain to check. - * @returns A promise that resolves to a boolean indicating if the domain is known for phishing. - */ - abstract getKnownPhishingDomains: () => Promise; } diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 58d6d9efef9..363b82c507d 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -1,8 +1,13 @@ -import { map, Observable } from "rxjs"; +import { combineLatest, map, Observable } from "rxjs"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { UserId } from "../../../types/guid"; +import { PolicyType } from "../../enums"; import { OrganizationData } from "../../models/data/organization.data"; import { Organization } from "../../models/domain/organization"; +import { PolicyService } from "../policy/policy.service.abstraction"; export function canAccessVaultTab(org: Organization): boolean { return org.canViewAllCollections; @@ -51,6 +56,17 @@ export function canAccessOrgAdmin(org: Organization): boolean { ); } +export function canAccessEmergencyAccess( + userId: UserId, + configService: ConfigService, + policyService: PolicyService, +) { + return combineLatest([ + configService.getFeatureFlag$(FeatureFlag.AutoConfirm), + policyService.policiesByType$(PolicyType.AutoConfirm, userId), + ]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled))); +} + /** * @deprecated Please use the general `getById` custom rxjs operator instead. */ diff --git a/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts index 6b50f9befec..8ce1a785516 100644 --- a/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts @@ -554,6 +554,77 @@ describe("PolicyService", () => { expect(result).toBe(false); }); + + describe("SingleOrg policy exemptions", () => { + it("returns true for SingleOrg policy when AutoConfirm is enabled, even for users who can manage policies", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org6", PolicyType.SingleOrg, true), + policyData("policy2", "org6", PolicyType.AutoConfirm, true), + ]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ); + + expect(result).toBe(true); + }); + + it("returns false for SingleOrg policy when user can manage policies and AutoConfirm is not enabled", async () => { + singleUserState.nextState( + arrayToRecord([policyData("policy1", "org6", PolicyType.SingleOrg, true)]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ); + + expect(result).toBe(false); + }); + + it("returns false for SingleOrg policy when user can manage policies and AutoConfirm is disabled", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org6", PolicyType.SingleOrg, true), + policyData("policy2", "org6", PolicyType.AutoConfirm, false), + ]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ); + + expect(result).toBe(false); + }); + + it("returns true for SingleOrg policy for regular users when AutoConfirm is not enabled", async () => { + singleUserState.nextState( + arrayToRecord([policyData("policy1", "org1", PolicyType.SingleOrg, true)]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ); + + expect(result).toBe(true); + }); + + it("returns true for SingleOrg policy when AutoConfirm is enabled in a different organization", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org6", PolicyType.SingleOrg, true), + policyData("policy2", "org1", PolicyType.AutoConfirm, true), + ]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ); + + expect(result).toBe(false); + }); + }); }); describe("combinePoliciesIntoMasterPasswordPolicyOptions", () => { diff --git a/libs/common/src/admin-console/services/policy/default-policy.service.ts b/libs/common/src/admin-console/services/policy/default-policy.service.ts index 5781dd938f3..1107e88e796 100644 --- a/libs/common/src/admin-console/services/policy/default-policy.service.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.ts @@ -40,18 +40,16 @@ export class DefaultPolicyService implements PolicyService { } policiesByType$(policyType: PolicyType, userId: UserId) { - const filteredPolicies$ = this.policies$(userId).pipe( - map((policies) => policies.filter((p) => p.type === policyType)), - ); - if (!userId) { throw new Error("No userId provided"); } + const allPolicies$ = this.policies$(userId); const organizations$ = this.organizationService.organizations$(userId); - return combineLatest([filteredPolicies$, organizations$]).pipe( + return combineLatest([allPolicies$, organizations$]).pipe( map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)), + map((policies) => policies.filter((p) => p.type === policyType)), ); } @@ -77,7 +75,7 @@ export class DefaultPolicyService implements PolicyService { policy.enabled && organization.status >= OrganizationUserStatusType.Accepted && organization.usePolicies && - !this.isExemptFromPolicy(policy.type, organization) + !this.isExemptFromPolicy(policy.type, organization, policies) ); }); } @@ -265,7 +263,11 @@ export class DefaultPolicyService implements PolicyService { * Determines whether an orgUser is exempt from a specific policy because of their role * Generally orgUsers who can manage policies are exempt from them, but some policies are stricter */ - private isExemptFromPolicy(policyType: PolicyType, organization: Organization) { + private isExemptFromPolicy( + policyType: PolicyType, + organization: Organization, + allPolicies: Policy[], + ) { switch (policyType) { case PolicyType.MaximumVaultTimeout: // Max Vault Timeout applies to everyone except owners @@ -286,6 +288,14 @@ export class DefaultPolicyService implements PolicyService { case PolicyType.OrganizationDataOwnership: // organization data ownership policy applies to everyone except admins and owners return organization.isAdmin; + case PolicyType.SingleOrg: + // Check if AutoConfirm policy is enabled for this organization + return allPolicies.find( + (p) => + p.organizationId === organization.id && p.type === PolicyType.AutoConfirm && p.enabled, + ) + ? false + : organization.canManagePolicies; default: return organization.canManagePolicies; } diff --git a/libs/common/src/auth/models/response/identity-token.response.ts b/libs/common/src/auth/models/response/identity-token.response.ts index 7f490203131..dab96f6cf8c 100644 --- a/libs/common/src/auth/models/response/identity-token.response.ts +++ b/libs/common/src/auth/models/response/identity-token.response.ts @@ -11,11 +11,13 @@ import { MasterPasswordPolicyResponse } from "./master-password-policy.response" import { UserDecryptionOptionsResponse } from "./user-decryption-options/user-decryption-options.response"; export class IdentityTokenResponse extends BaseResponse { + // Authentication Information accessToken: string; expiresIn?: number; refreshToken?: string; tokenType: string; + // Decryption Information resetMasterPassword: boolean; privateKey: string; // userKeyEncryptedPrivateKey key?: EncString; // masterKeyEncryptedUserKey diff --git a/libs/common/src/billing/abstractions/subscription-pricing.service.abstraction.ts b/libs/common/src/billing/abstractions/subscription-pricing.service.abstraction.ts new file mode 100644 index 00000000000..f3928c0e2e7 --- /dev/null +++ b/libs/common/src/billing/abstractions/subscription-pricing.service.abstraction.ts @@ -0,0 +1,32 @@ +import { Observable } from "rxjs"; + +import { + BusinessSubscriptionPricingTier, + PersonalSubscriptionPricingTier, +} from "../types/subscription-pricing-tier"; + +export abstract class SubscriptionPricingServiceAbstraction { + /** + * Gets personal subscription pricing tiers (Premium and Families). + * Throws any errors that occur during api request so callers must handle errors. + * @returns An observable of an array of personal subscription pricing tiers. + * @throws Error if any errors occur during api request. + */ + abstract getPersonalSubscriptionPricingTiers$(): Observable; + + /** + * Gets business subscription pricing tiers (Teams, Enterprise, and Custom). + * Throws any errors that occur during api request so callers must handle errors. + * @returns An observable of an array of business subscription pricing tiers. + * @throws Error if any errors occur during api request. + */ + abstract getBusinessSubscriptionPricingTiers$(): Observable; + + /** + * Gets developer subscription pricing tiers (Free, Teams, and Enterprise). + * Throws any errors that occur during api request so callers must handle errors. + * @returns An observable of an array of business subscription pricing tiers for developers. + * @throws Error if any errors occur during api request. + */ + abstract getDeveloperSubscriptionPricingTiers$(): Observable; +} diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts b/libs/common/src/billing/services/subscription-pricing.service.spec.ts similarity index 87% rename from apps/web/src/app/billing/services/subscription-pricing.service.spec.ts rename to libs/common/src/billing/services/subscription-pricing.service.spec.ts index de80cdcbdbf..07ad292c568 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.spec.ts @@ -1,4 +1,3 @@ -import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; @@ -8,7 +7,6 @@ import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.res import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; import { @@ -17,15 +15,14 @@ import { SubscriptionCadenceIds, } from "../types/subscription-pricing-tier"; -import { SubscriptionPricingService } from "./subscription-pricing.service"; +import { DefaultSubscriptionPricingService } from "./subscription-pricing.service"; -describe("SubscriptionPricingService", () => { - let service: SubscriptionPricingService; +describe("DefaultSubscriptionPricingService", () => { + let service: DefaultSubscriptionPricingService; let billingApiService: MockProxy; let configService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; - let toastService: MockProxy; const mockFamiliesPlan = { type: PlanType.FamiliesAnnually, @@ -233,7 +230,6 @@ describe("SubscriptionPricingService", () => { beforeAll(() => { i18nService = mock(); logService = mock(); - toastService = mock(); i18nService.t.mockImplementation((key: string, ...args: any[]) => { switch (key) { @@ -324,8 +320,6 @@ describe("SubscriptionPricingService", () => { return "Boost productivity"; case "seamlessIntegration": return "Seamless integration"; - case "unexpectedError": - return "An unexpected error has occurred."; default: return key; } @@ -340,18 +334,12 @@ describe("SubscriptionPricingService", () => { billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value) - TestBed.configureTestingModule({ - providers: [ - SubscriptionPricingService, - { provide: BillingApiServiceAbstraction, useValue: billingApiService }, - { provide: ConfigService, useValue: configService }, - { provide: I18nService, useValue: i18nService }, - { provide: LogService, useValue: logService }, - { provide: ToastService, useValue: toastService }, - ], - }); - - service = TestBed.inject(SubscriptionPricingService); + service = new DefaultSubscriptionPricingService( + billingApiService, + configService, + i18nService, + logService, + ); }); describe("getPersonalSubscriptionPricingTiers$", () => { @@ -422,46 +410,37 @@ describe("SubscriptionPricingService", () => { }); }); - it("should handle API errors by logging and showing toast", (done) => { + it("should handle API errors by logging and throwing error", (done) => { const errorBillingApiService = mock(); const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); - const errorToastService = mock(); const testError = new Error("API error"); errorBillingApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); - errorI18nService.t.mockImplementation((key: string) => { - if (key === "unexpectedError") { - return "An unexpected error has occurred."; - } - return key; - }); + errorI18nService.t.mockImplementation((key: string) => key); - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorBillingApiService, errorConfigService, errorI18nService, errorLogService, - errorToastService, ); errorService.getPersonalSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - expect(tiers).toEqual([]); - expect(errorLogService.error).toHaveBeenCalledWith(testError); - expect(errorToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(errorLogService.error).toHaveBeenCalledWith( + "Failed to load personal subscription pricing tiers", + testError, + ); + expect(error).toBe(testError); + done(); }, }); }); @@ -611,46 +590,37 @@ describe("SubscriptionPricingService", () => { }); }); - it("should handle API errors by logging and showing toast", (done) => { + it("should handle API errors by logging and throwing error", (done) => { const errorBillingApiService = mock(); const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); - const errorToastService = mock(); const testError = new Error("API error"); errorBillingApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); - errorI18nService.t.mockImplementation((key: string) => { - if (key === "unexpectedError") { - return "An unexpected error has occurred."; - } - return key; - }); + errorI18nService.t.mockImplementation((key: string) => key); - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorBillingApiService, errorConfigService, errorI18nService, errorLogService, - errorToastService, ); errorService.getBusinessSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - expect(tiers).toEqual([]); - expect(errorLogService.error).toHaveBeenCalledWith(testError); - expect(errorToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(errorLogService.error).toHaveBeenCalledWith( + "Failed to load business subscription pricing tiers", + testError, + ); + expect(error).toBe(testError); + done(); }, }); }); @@ -855,46 +825,37 @@ describe("SubscriptionPricingService", () => { }); }); - it("should handle API errors by logging and showing toast", (done) => { + it("should handle API errors by logging and throwing error", (done) => { const errorBillingApiService = mock(); const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); - const errorToastService = mock(); const testError = new Error("API error"); errorBillingApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); - errorI18nService.t.mockImplementation((key: string) => { - if (key === "unexpectedError") { - return "An unexpected error has occurred."; - } - return key; - }); + errorI18nService.t.mockImplementation((key: string) => key); - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorBillingApiService, errorConfigService, errorI18nService, errorLogService, - errorToastService, ); errorService.getDeveloperSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - expect(tiers).toEqual([]); - expect(errorLogService.error).toHaveBeenCalledWith(testError); - expect(errorToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(errorLogService.error).toHaveBeenCalledWith( + "Failed to load developer subscription pricing tiers", + testError, + ); + expect(error).toBe(testError); + done(); }, }); }); @@ -910,38 +871,36 @@ describe("SubscriptionPricingService", () => { errorBillingApiService.getPremiumPlan.mockRejectedValue(testError); errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorBillingApiService, errorConfigService, i18nService, logService, - toastService, ); errorService.getPersonalSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - // Should return empty array due to error in premium plan fetch - expect(tiers).toEqual([]); + next: () => { + fail("Observable should error, not return a value"); + }, + error: (error: unknown) => { expect(logService.error).toHaveBeenCalledWith( "Failed to fetch premium plan from API", testError, ); - expect(toastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); + expect(logService.error).toHaveBeenCalledWith( + "Failed to load personal subscription pricing tiers", + testError, + ); + expect(error).toBe(testError); done(); }, - error: () => { - fail("Observable should not error, it should return empty array"); - }, }); }); it("should handle malformed premium plan API response", (done) => { const errorBillingApiService = mock(); const errorConfigService = mock(); + const testError = new TypeError("Cannot read properties of undefined (reading 'price')"); // Malformed response missing the Seat property const malformedResponse = { @@ -955,28 +914,24 @@ describe("SubscriptionPricingService", () => { errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any); errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorBillingApiService, errorConfigService, i18nService, logService, - toastService, ); errorService.getPersonalSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - // Should return empty array due to validation error - expect(tiers).toEqual([]); - expect(logService.error).toHaveBeenCalled(); - expect(toastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(logService.error).toHaveBeenCalledWith( + "Failed to load personal subscription pricing tiers", + testError, + ); + expect(error).toEqual(testError); + done(); }, }); }); @@ -984,6 +939,7 @@ describe("SubscriptionPricingService", () => { it("should handle malformed premium plan with invalid price types", (done) => { const errorBillingApiService = mock(); const errorConfigService = mock(); + const testError = new TypeError("Cannot read properties of undefined (reading 'price')"); // Malformed response with price as string instead of number const malformedResponse = { @@ -1001,28 +957,24 @@ describe("SubscriptionPricingService", () => { errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any); errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorBillingApiService, errorConfigService, i18nService, logService, - toastService, ); errorService.getPersonalSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - // Should return empty array due to validation error - expect(tiers).toEqual([]); - expect(logService.error).toHaveBeenCalled(); - expect(toastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(logService.error).toHaveBeenCalledWith( + "Failed to load personal subscription pricing tiers", + testError, + ); + expect(error).toEqual(testError); + done(); }, }); }); @@ -1053,12 +1005,11 @@ describe("SubscriptionPricingService", () => { const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan"); // Create a new service instance with the feature flag enabled - const newService = new SubscriptionPricingService( + const newService = new DefaultSubscriptionPricingService( newBillingApiService, newConfigService, i18nService, logService, - toastService, ); // Subscribe to the premium pricing tier multiple times @@ -1082,12 +1033,11 @@ describe("SubscriptionPricingService", () => { newConfigService.getFeatureFlag$.mockReturnValue(of(false)); // Create a new service instance with the feature flag disabled - const newService = new SubscriptionPricingService( + const newService = new DefaultSubscriptionPricingService( newBillingApiService, newConfigService, i18nService, logService, - toastService, ); // Subscribe with feature flag disabled diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.ts b/libs/common/src/billing/services/subscription-pricing.service.ts similarity index 87% rename from apps/web/src/app/billing/services/subscription-pricing.service.ts rename to libs/common/src/billing/services/subscription-pricing.service.ts index 71729a42d23..a4223579c12 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.ts @@ -1,5 +1,14 @@ -import { Injectable } from "@angular/core"; -import { combineLatest, from, map, Observable, of, shareReplay, switchMap, take } from "rxjs"; +import { + combineLatest, + from, + map, + Observable, + of, + shareReplay, + switchMap, + take, + throwError, +} from "rxjs"; import { catchError } from "rxjs/operators"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; @@ -10,19 +19,18 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; -import { BillingServicesModule } from "@bitwarden/web-vault/app/billing/services/billing-services.module"; + +import { SubscriptionPricingServiceAbstraction } from "../abstractions/subscription-pricing.service.abstraction"; import { BusinessSubscriptionPricingTier, BusinessSubscriptionPricingTierIds, PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierIds, SubscriptionCadenceIds, -} from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier"; +} from "../types/subscription-pricing-tier"; -@Injectable({ providedIn: BillingServicesModule }) -export class SubscriptionPricingService { +export class DefaultSubscriptionPricingService implements SubscriptionPricingServiceAbstraction { /** * Fallback premium pricing used when the feature flag is disabled. * These values represent the legacy pricing model and will not reflect @@ -37,33 +45,47 @@ export class SubscriptionPricingService { private configService: ConfigService, private i18nService: I18nService, private logService: LogService, - private toastService: ToastService, ) {} + /** + * Gets personal subscription pricing tiers (Premium and Families). + * Throws any errors that occur during api request so callers must handle errors. + * @returns An observable of an array of personal subscription pricing tiers. + * @throws Error if any errors occur during api request. + */ getPersonalSubscriptionPricingTiers$ = (): Observable => combineLatest([this.premium$, this.families$]).pipe( catchError((error: unknown) => { - this.logService.error(error); - this.showUnexpectedErrorToast(); - return of([]); + this.logService.error("Failed to load personal subscription pricing tiers", error); + return throwError(() => error); }), ); + /** + * Gets business subscription pricing tiers (Teams, Enterprise, and Custom). + * Throws any errors that occur during api request so callers must handle errors. + * @returns An observable of an array of business subscription pricing tiers. + * @throws Error if any errors occur during api request. + */ getBusinessSubscriptionPricingTiers$ = (): Observable => combineLatest([this.teams$, this.enterprise$, this.custom$]).pipe( catchError((error: unknown) => { - this.logService.error(error); - this.showUnexpectedErrorToast(); - return of([]); + this.logService.error("Failed to load business subscription pricing tiers", error); + return throwError(() => error); }), ); + /** + * Gets developer subscription pricing tiers (Free, Teams, and Enterprise). + * Throws any errors that occur during api request so callers must handle errors. + * @returns An observable of an array of business subscription pricing tiers for developers. + * @throws Error if any errors occur during api request. + */ getDeveloperSubscriptionPricingTiers$ = (): Observable => combineLatest([this.free$, this.teams$, this.enterprise$]).pipe( catchError((error: unknown) => { - this.logService.error(error); - this.showUnexpectedErrorToast(); - return of([]); + this.logService.error("Failed to load developer subscription pricing tiers", error); + return throwError(() => error); }), ); @@ -76,7 +98,7 @@ export class SubscriptionPricingService { ).pipe( catchError((error: unknown) => { this.logService.error("Failed to fetch premium plan from API", error); - throw error; // Re-throw to propagate to higher-level error handler + return throwError(() => error); // Re-throw to propagate to higher-level error handler }), shareReplay({ bufferSize: 1, refCount: false }), ); @@ -94,8 +116,8 @@ export class SubscriptionPricingService { })), ) : of({ - seat: SubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE, - storage: SubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE, + seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE, + storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE, }), ), map((premiumPrices) => ({ @@ -268,14 +290,6 @@ export class SubscriptionPricingService { ), ); - private showUnexpectedErrorToast() { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("unexpectedError"), - }); - } - private featureTranslations = { builtInAuthenticator: () => ({ key: "builtInAuthenticator", diff --git a/apps/web/src/app/billing/types/subscription-pricing-tier.ts b/libs/common/src/billing/types/subscription-pricing-tier.ts similarity index 100% rename from apps/web/src/app/billing/types/subscription-pricing-tier.ts rename to libs/common/src/billing/types/subscription-pricing-tier.ts diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index e4667c73603..d9effd21b30 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -30,6 +30,7 @@ export enum FeatureFlag { PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page", PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service", + PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -118,6 +119,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, [FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE, [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, + [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/common/src/key-management/master-password/models/response/master-password-unlock.response.ts b/libs/common/src/key-management/master-password/models/response/master-password-unlock.response.ts index 41b16d43f56..9f2c39dd1ae 100644 --- a/libs/common/src/key-management/master-password/models/response/master-password-unlock.response.ts +++ b/libs/common/src/key-management/master-password/models/response/master-password-unlock.response.ts @@ -22,13 +22,15 @@ export class MasterPasswordUnlockResponse extends BaseResponse { this.kdf = new KdfConfigResponse(this.getResponseProperty("Kdf")); - const masterKeyEncryptedUserKey = this.getResponseProperty("MasterKeyEncryptedUserKey"); - if (masterKeyEncryptedUserKey == null || typeof masterKeyEncryptedUserKey !== "string") { + // Note: MasterKeyEncryptedUserKey and masterKeyWrappedUserKey are the same thing, and + // used inconsistently in the codebase + const masterKeyWrappedUserKey = this.getResponseProperty("MasterKeyEncryptedUserKey"); + if (masterKeyWrappedUserKey == null || typeof masterKeyWrappedUserKey !== "string") { throw new Error( "MasterPasswordUnlockResponse does not contain a valid master key encrypted user key", ); } - this.masterKeyWrappedUserKey = masterKeyEncryptedUserKey as MasterKeyWrappedUserKey; + this.masterKeyWrappedUserKey = masterKeyWrappedUserKey as MasterKeyWrappedUserKey; } toMasterPasswordUnlockData() { diff --git a/libs/common/src/services/audit.service.ts b/libs/common/src/services/audit.service.ts index 8fc9f13476d..0bdf45917de 100644 --- a/libs/common/src/services/audit.service.ts +++ b/libs/common/src/services/audit.service.ts @@ -80,9 +80,4 @@ export class AuditService implements AuditServiceAbstraction { throw new Error(); } } - - async getKnownPhishingDomains(): Promise { - const response = await this.apiService.send("GET", "/phishing-domains", null, true, true); - return response as string[]; - } } diff --git a/libs/common/src/vault/models/data/attachment.data.ts b/libs/common/src/vault/models/data/attachment.data.ts index dfc9f9d1afa..dde5db24b57 100644 --- a/libs/common/src/vault/models/data/attachment.data.ts +++ b/libs/common/src/vault/models/data/attachment.data.ts @@ -1,14 +1,12 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { AttachmentResponse } from "../response/attachment.response"; export class AttachmentData { - id: string; - url: string; - fileName: string; - key: string; - size: string; - sizeName: string; + id?: string; + url?: string; + fileName?: string; + key?: string; + size?: string; + sizeName?: string; constructor(response?: AttachmentResponse) { if (response == null) { diff --git a/libs/common/src/vault/models/data/card.data.ts b/libs/common/src/vault/models/data/card.data.ts index 677c33f4886..8345c345fd7 100644 --- a/libs/common/src/vault/models/data/card.data.ts +++ b/libs/common/src/vault/models/data/card.data.ts @@ -1,14 +1,12 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CardApi } from "../api/card.api"; export class CardData { - cardholderName: string; - brand: string; - number: string; - expMonth: string; - expYear: string; - code: string; + cardholderName?: string; + brand?: string; + number?: string; + expMonth?: string; + expYear?: string; + code?: string; constructor(data?: CardApi) { if (data == null) { diff --git a/libs/common/src/vault/models/data/cipher.data.ts b/libs/common/src/vault/models/data/cipher.data.ts index 4921cce8df2..743ea941b0e 100644 --- a/libs/common/src/vault/models/data/cipher.data.ts +++ b/libs/common/src/vault/models/data/cipher.data.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; @@ -17,18 +15,18 @@ import { SecureNoteData } from "./secure-note.data"; import { SshKeyData } from "./ssh-key.data"; export class CipherData { - id: string; - organizationId: string; - folderId: string; - edit: boolean; - viewPassword: boolean; - permissions: CipherPermissionsApi; - organizationUseTotp: boolean; - favorite: boolean; + id: string = ""; + organizationId?: string; + folderId?: string; + edit: boolean = false; + viewPassword: boolean = true; + permissions?: CipherPermissionsApi; + organizationUseTotp: boolean = false; + favorite: boolean = false; revisionDate: string; - type: CipherType; - name: string; - notes: string; + type: CipherType = CipherType.Login; + name: string = ""; + notes?: string; login?: LoginData; secureNote?: SecureNoteData; card?: CardData; @@ -39,13 +37,14 @@ export class CipherData { passwordHistory?: PasswordHistoryData[]; collectionIds?: string[]; creationDate: string; - deletedDate: string | undefined; - archivedDate: string | undefined; - reprompt: CipherRepromptType; - key: string; + deletedDate?: string; + archivedDate?: string; + reprompt: CipherRepromptType = CipherRepromptType.None; + key?: string; constructor(response?: CipherResponse, collectionIds?: string[]) { if (response == null) { + this.creationDate = this.revisionDate = new Date().toISOString(); return; } @@ -101,7 +100,9 @@ export class CipherData { static fromJSON(obj: Jsonify) { const result = Object.assign(new CipherData(), obj); - result.permissions = CipherPermissionsApi.fromJSON(obj.permissions); + if (obj.permissions != null) { + result.permissions = CipherPermissionsApi.fromJSON(obj.permissions); + } return result; } } diff --git a/libs/common/src/vault/models/data/fido2-credential.data.ts b/libs/common/src/vault/models/data/fido2-credential.data.ts index 94716e8d86c..602b74f9805 100644 --- a/libs/common/src/vault/models/data/fido2-credential.data.ts +++ b/libs/common/src/vault/models/data/fido2-credential.data.ts @@ -1,21 +1,19 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Fido2CredentialApi } from "../api/fido2-credential.api"; export class Fido2CredentialData { - credentialId: string; - keyType: "public-key"; - keyAlgorithm: "ECDSA"; - keyCurve: "P-256"; - keyValue: string; - rpId: string; - userHandle: string; - userName: string; - counter: string; - rpName: string; - userDisplayName: string; - discoverable: string; - creationDate: string; + credentialId!: string; + keyType!: string; + keyAlgorithm!: string; + keyCurve!: string; + keyValue!: string; + rpId!: string; + userHandle?: string; + userName?: string; + counter!: string; + rpName?: string; + userDisplayName?: string; + discoverable!: string; + creationDate!: string; constructor(data?: Fido2CredentialApi) { if (data == null) { diff --git a/libs/common/src/vault/models/data/field.data.ts b/libs/common/src/vault/models/data/field.data.ts index cf9df69a6b0..a63e903e665 100644 --- a/libs/common/src/vault/models/data/field.data.ts +++ b/libs/common/src/vault/models/data/field.data.ts @@ -1,13 +1,11 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { FieldType, LinkedIdType } from "../../enums"; import { FieldApi } from "../api/field.api"; export class FieldData { - type: FieldType; - name: string; - value: string; - linkedId: LinkedIdType | null; + type: FieldType = FieldType.Text; + name?: string; + value?: string; + linkedId?: LinkedIdType; constructor(response?: FieldApi) { if (response == null) { diff --git a/libs/common/src/vault/models/data/identity.data.ts b/libs/common/src/vault/models/data/identity.data.ts index 158daace371..de854df1ec6 100644 --- a/libs/common/src/vault/models/data/identity.data.ts +++ b/libs/common/src/vault/models/data/identity.data.ts @@ -1,26 +1,24 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { IdentityApi } from "../api/identity.api"; export class IdentityData { - title: string; - firstName: string; - middleName: string; - lastName: string; - address1: string; - address2: string; - address3: string; - city: string; - state: string; - postalCode: string; - country: string; - company: string; - email: string; - phone: string; - ssn: string; - username: string; - passportNumber: string; - licenseNumber: string; + title?: string; + firstName?: string; + middleName?: string; + lastName?: string; + address1?: string; + address2?: string; + address3?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + company?: string; + email?: string; + phone?: string; + ssn?: string; + username?: string; + passportNumber?: string; + licenseNumber?: string; constructor(data?: IdentityApi) { if (data == null) { diff --git a/libs/common/src/vault/models/data/login-uri.data.ts b/libs/common/src/vault/models/data/login-uri.data.ts index 852dad4e112..ea3a1d9adce 100644 --- a/libs/common/src/vault/models/data/login-uri.data.ts +++ b/libs/common/src/vault/models/data/login-uri.data.ts @@ -1,12 +1,10 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { LoginUriApi } from "../api/login-uri.api"; export class LoginUriData { - uri: string; - uriChecksum: string; - match: UriMatchStrategySetting = null; + uri?: string; + uriChecksum?: string; + match?: UriMatchStrategySetting; constructor(data?: LoginUriApi) { if (data == null) { diff --git a/libs/common/src/vault/models/data/login.data.ts b/libs/common/src/vault/models/data/login.data.ts index 0fe021d923c..8c0aba0fdaa 100644 --- a/libs/common/src/vault/models/data/login.data.ts +++ b/libs/common/src/vault/models/data/login.data.ts @@ -1,17 +1,15 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { LoginApi } from "../api/login.api"; import { Fido2CredentialData } from "./fido2-credential.data"; import { LoginUriData } from "./login-uri.data"; export class LoginData { - uris: LoginUriData[]; - username: string; - password: string; - passwordRevisionDate: string; - totp: string; - autofillOnPageLoad: boolean; + uris?: LoginUriData[]; + username?: string; + password?: string; + passwordRevisionDate?: string; + totp?: string; + autofillOnPageLoad?: boolean; fido2Credentials?: Fido2CredentialData[]; constructor(data?: LoginApi) { diff --git a/libs/common/src/vault/models/data/password-history.data.ts b/libs/common/src/vault/models/data/password-history.data.ts index 75a51ed3728..465b5e59b8d 100644 --- a/libs/common/src/vault/models/data/password-history.data.ts +++ b/libs/common/src/vault/models/data/password-history.data.ts @@ -1,10 +1,8 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { PasswordHistoryResponse } from "../response/password-history.response"; export class PasswordHistoryData { - password: string; - lastUsedDate: string; + password!: string; + lastUsedDate!: string; constructor(response?: PasswordHistoryResponse) { if (response == null) { diff --git a/libs/common/src/vault/models/data/secure-note.data.ts b/libs/common/src/vault/models/data/secure-note.data.ts index 7d109398ab7..5556417ef9b 100644 --- a/libs/common/src/vault/models/data/secure-note.data.ts +++ b/libs/common/src/vault/models/data/secure-note.data.ts @@ -1,10 +1,8 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { SecureNoteType } from "../../enums"; import { SecureNoteApi } from "../api/secure-note.api"; export class SecureNoteData { - type: SecureNoteType; + type: SecureNoteType = SecureNoteType.Generic; constructor(data?: SecureNoteApi) { if (data == null) { diff --git a/libs/common/src/vault/models/data/ssh-key.data.ts b/libs/common/src/vault/models/data/ssh-key.data.ts index 2b93c93d931..1e06a1a7df5 100644 --- a/libs/common/src/vault/models/data/ssh-key.data.ts +++ b/libs/common/src/vault/models/data/ssh-key.data.ts @@ -1,11 +1,9 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { SshKeyApi } from "../api/ssh-key.api"; export class SshKeyData { - privateKey: string; - publicKey: string; - keyFingerprint: string; + privateKey!: string; + publicKey!: string; + keyFingerprint!: string; constructor(data?: SshKeyApi) { if (data == null) { diff --git a/libs/common/src/vault/models/domain/attachment.spec.ts b/libs/common/src/vault/models/domain/attachment.spec.ts index 972c77537ff..77bb3eda38d 100644 --- a/libs/common/src/vault/models/domain/attachment.spec.ts +++ b/libs/common/src/vault/models/domain/attachment.spec.ts @@ -39,6 +39,12 @@ describe("Attachment", () => { key: undefined, fileName: undefined, }); + expect(data.id).toBeUndefined(); + expect(data.url).toBeUndefined(); + expect(data.fileName).toBeUndefined(); + expect(data.key).toBeUndefined(); + expect(data.size).toBeUndefined(); + expect(data.sizeName).toBeUndefined(); }); it("Convert", () => { diff --git a/libs/common/src/vault/models/domain/card.spec.ts b/libs/common/src/vault/models/domain/card.spec.ts index a4d242329a4..185c2fa4b8f 100644 --- a/libs/common/src/vault/models/domain/card.spec.ts +++ b/libs/common/src/vault/models/domain/card.spec.ts @@ -29,6 +29,13 @@ describe("Card", () => { expYear: undefined, code: undefined, }); + + expect(data.cardholderName).toBeUndefined(); + expect(data.brand).toBeUndefined(); + expect(data.number).toBeUndefined(); + expect(data.expMonth).toBeUndefined(); + expect(data.expYear).toBeUndefined(); + expect(data.code).toBeUndefined(); }); it("Convert", () => { diff --git a/libs/common/src/vault/models/domain/cipher.spec.ts b/libs/common/src/vault/models/domain/cipher.spec.ts index 4052c9e5338..87301928c57 100644 --- a/libs/common/src/vault/models/domain/cipher.spec.ts +++ b/libs/common/src/vault/models/domain/cipher.spec.ts @@ -44,22 +44,22 @@ describe("Cipher DTO", () => { const data = new CipherData(); const cipher = new Cipher(data); - expect(cipher.id).toBeUndefined(); + expect(cipher.id).toEqual(""); expect(cipher.organizationId).toBeUndefined(); expect(cipher.folderId).toBeUndefined(); expect(cipher.name).toBeInstanceOf(EncString); expect(cipher.notes).toBeUndefined(); - expect(cipher.type).toBeUndefined(); - expect(cipher.favorite).toBeUndefined(); - expect(cipher.organizationUseTotp).toBeUndefined(); - expect(cipher.edit).toBeUndefined(); - expect(cipher.viewPassword).toBeUndefined(); + expect(cipher.type).toEqual(CipherType.Login); + expect(cipher.favorite).toEqual(false); + expect(cipher.organizationUseTotp).toEqual(false); + expect(cipher.edit).toEqual(false); + expect(cipher.viewPassword).toEqual(true); expect(cipher.revisionDate).toBeInstanceOf(Date); expect(cipher.collectionIds).toEqual([]); expect(cipher.localData).toBeUndefined(); expect(cipher.creationDate).toBeInstanceOf(Date); expect(cipher.deletedDate).toBeUndefined(); - expect(cipher.reprompt).toBeUndefined(); + expect(cipher.reprompt).toEqual(CipherRepromptType.None); expect(cipher.attachments).toBeUndefined(); expect(cipher.fields).toBeUndefined(); expect(cipher.passwordHistory).toBeUndefined(); @@ -836,6 +836,38 @@ describe("Cipher DTO", () => { expect(actual).toBeInstanceOf(Cipher); }); + it("handles null permissions correctly without calling CipherPermissionsApi constructor", () => { + const spy = jest.spyOn(CipherPermissionsApi.prototype, "constructor" as any); + const revisionDate = new Date("2022-08-04T01:06:40.441Z"); + const actual = Cipher.fromJSON({ + name: "myName", + revisionDate: revisionDate.toISOString(), + permissions: null, + } as Jsonify); + + expect(actual.permissions).toBeUndefined(); + expect(actual).toBeInstanceOf(Cipher); + // Verify that CipherPermissionsApi constructor was not called for null permissions + expect(spy).not.toHaveBeenCalledWith(null); + spy.mockRestore(); + }); + + it("calls CipherPermissionsApi constructor when permissions are provided", () => { + const spy = jest.spyOn(CipherPermissionsApi.prototype, "constructor" as any); + const revisionDate = new Date("2022-08-04T01:06:40.441Z"); + const permissionsObj = { delete: true, restore: false }; + const actual = Cipher.fromJSON({ + name: "myName", + revisionDate: revisionDate.toISOString(), + permissions: permissionsObj, + } as Jsonify); + + expect(actual.permissions).toBeInstanceOf(CipherPermissionsApi); + expect(actual.permissions.delete).toBe(true); + expect(actual.permissions.restore).toBe(false); + spy.mockRestore(); + }); + test.each([ // Test description, CipherType, expected output ["LoginView", CipherType.Login, { login: "myLogin_fromJSON" }], @@ -1056,6 +1088,7 @@ describe("Cipher DTO", () => { card: undefined, secureNote: undefined, sshKey: undefined, + data: undefined, favorite: false, reprompt: SdkCipherRepromptType.None, organizationUseTotp: true, diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 5e284232936..5739a9a50a7 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -421,6 +421,7 @@ export class Cipher extends Domain implements Decryptable { card: undefined, secureNote: undefined, sshKey: undefined, + data: undefined, }; switch (this.type) { diff --git a/libs/common/src/vault/models/domain/field.spec.ts b/libs/common/src/vault/models/domain/field.spec.ts index d99336adad0..0a4bc8e3c29 100644 --- a/libs/common/src/vault/models/domain/field.spec.ts +++ b/libs/common/src/vault/models/domain/field.spec.ts @@ -29,7 +29,7 @@ describe("Field", () => { const field = new Field(data); expect(field).toEqual({ - type: undefined, + type: FieldType.Text, name: undefined, value: undefined, linkedId: undefined, diff --git a/libs/common/src/vault/models/domain/identity.spec.ts b/libs/common/src/vault/models/domain/identity.spec.ts index c2c2363fa0d..411f6d1c9ea 100644 --- a/libs/common/src/vault/models/domain/identity.spec.ts +++ b/libs/common/src/vault/models/domain/identity.spec.ts @@ -53,6 +53,27 @@ describe("Identity", () => { title: undefined, username: undefined, }); + + expect(data).toEqual({ + title: undefined, + firstName: undefined, + middleName: undefined, + lastName: undefined, + address1: undefined, + address2: undefined, + address3: undefined, + city: undefined, + state: undefined, + postalCode: undefined, + country: undefined, + company: undefined, + email: undefined, + phone: undefined, + ssn: undefined, + username: undefined, + passportNumber: undefined, + licenseNumber: undefined, + }); }); it("Convert", () => { diff --git a/libs/common/src/vault/models/domain/login-uri.spec.ts b/libs/common/src/vault/models/domain/login-uri.spec.ts index 982b435384b..2effd1bb9fe 100644 --- a/libs/common/src/vault/models/domain/login-uri.spec.ts +++ b/libs/common/src/vault/models/domain/login-uri.spec.ts @@ -7,6 +7,7 @@ import { mockEnc, mockFromJson } from "../../../../spec"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../../key-management/crypto/models/enc-string"; import { UriMatchStrategy } from "../../../models/domain/domain-service"; +import { LoginUriApi } from "../api/login-uri.api"; import { LoginUriData } from "../data/login-uri.data"; import { LoginUri } from "./login-uri"; @@ -31,6 +32,9 @@ describe("LoginUri", () => { uri: undefined, uriChecksum: undefined, }); + expect(data.uri).toBeUndefined(); + expect(data.uriChecksum).toBeUndefined(); + expect(data.match).toBeUndefined(); }); it("Convert", () => { @@ -61,6 +65,23 @@ describe("LoginUri", () => { }); }); + it("handle null match", () => { + const apiData = Object.assign(new LoginUriApi(), { + uri: "testUri", + uriChecksum: "testChecksum", + match: null, + }); + + const loginUriData = new LoginUriData(apiData); + + // The data model stores it as-is (null or undefined) + expect(loginUriData.match).toBeNull(); + + // But the domain model converts null to undefined + const loginUri = new LoginUri(loginUriData); + expect(loginUri.match).toBeUndefined(); + }); + describe("validateChecksum", () => { let encryptService: MockProxy; @@ -118,7 +139,7 @@ describe("LoginUri", () => { }); describe("SDK Login Uri Mapping", () => { - it("should map to SDK login uri", () => { + it("maps to SDK login uri", () => { const loginUri = new LoginUri(data); const sdkLoginUri = loginUri.toSdkLoginUri(); diff --git a/libs/common/src/vault/models/domain/login.spec.ts b/libs/common/src/vault/models/domain/login.spec.ts index 9f03e225b7f..6ebcfea057a 100644 --- a/libs/common/src/vault/models/domain/login.spec.ts +++ b/libs/common/src/vault/models/domain/login.spec.ts @@ -25,6 +25,14 @@ describe("Login DTO", () => { password: undefined, totp: undefined, }); + + expect(data.username).toBeUndefined(); + expect(data.password).toBeUndefined(); + expect(data.passwordRevisionDate).toBeUndefined(); + expect(data.totp).toBeUndefined(); + expect(data.autofillOnPageLoad).toBeUndefined(); + expect(data.uris).toBeUndefined(); + expect(data.fido2Credentials).toBeUndefined(); }); it("Convert from full LoginData", () => { diff --git a/libs/common/src/vault/models/domain/login.ts b/libs/common/src/vault/models/domain/login.ts index 13342c69014..a9cec13fc7c 100644 --- a/libs/common/src/vault/models/domain/login.ts +++ b/libs/common/src/vault/models/domain/login.ts @@ -111,10 +111,7 @@ export class Login extends Domain { }); if (this.uris != null && this.uris.length > 0) { - l.uris = []; - this.uris.forEach((u) => { - l.uris.push(u.toLoginUriData()); - }); + l.uris = this.uris.map((u) => u.toLoginUriData()); } if (this.fido2Credentials != null && this.fido2Credentials.length > 0) { diff --git a/libs/common/src/vault/models/domain/password.spec.ts b/libs/common/src/vault/models/domain/password.spec.ts index 2e37c5e8375..4b2de34beca 100644 --- a/libs/common/src/vault/models/domain/password.spec.ts +++ b/libs/common/src/vault/models/domain/password.spec.ts @@ -20,6 +20,9 @@ describe("Password", () => { expect(password).toBeInstanceOf(Password); expect(password.password).toBeInstanceOf(EncString); expect(password.lastUsedDate).toBeInstanceOf(Date); + + expect(data.password).toBeUndefined(); + expect(data.lastUsedDate).toBeUndefined(); }); it("Convert", () => { @@ -83,4 +86,47 @@ describe("Password", () => { }); }); }); + + describe("fromSdkPasswordHistory", () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it("creates Password from SDK object", () => { + const sdkPasswordHistory = { + password: "2.encPassword|encryptedData" as EncryptedString, + lastUsedDate: "2022-01-31T12:00:00.000Z", + }; + + const password = Password.fromSdkPasswordHistory(sdkPasswordHistory); + + expect(password).toBeInstanceOf(Password); + expect(password?.password).toBeInstanceOf(EncString); + expect(password?.password.encryptedString).toBe("2.encPassword|encryptedData"); + expect(password?.lastUsedDate).toEqual(new Date("2022-01-31T12:00:00.000Z")); + }); + + it("returns undefined for null input", () => { + const result = Password.fromSdkPasswordHistory(null as any); + expect(result).toBeUndefined(); + }); + + it("returns undefined for undefined input", () => { + const result = Password.fromSdkPasswordHistory(undefined); + expect(result).toBeUndefined(); + }); + + it("handles empty SDK object", () => { + const sdkPasswordHistory = { + password: "" as EncryptedString, + lastUsedDate: "", + }; + + const password = Password.fromSdkPasswordHistory(sdkPasswordHistory); + + expect(password).toBeInstanceOf(Password); + expect(password?.password).toBeInstanceOf(EncString); + expect(password?.lastUsedDate).toBeInstanceOf(Date); + }); + }); }); diff --git a/libs/common/src/vault/models/domain/secure-note.spec.ts b/libs/common/src/vault/models/domain/secure-note.spec.ts index 4c8e8d470ca..e445e9ea035 100644 --- a/libs/common/src/vault/models/domain/secure-note.spec.ts +++ b/libs/common/src/vault/models/domain/secure-note.spec.ts @@ -16,22 +16,27 @@ describe("SecureNote", () => { const data = new SecureNoteData(); const secureNote = new SecureNote(data); - expect(secureNote).toEqual({ - type: undefined, - }); + expect(data).toBeDefined(); + expect(secureNote).toEqual({ type: SecureNoteType.Generic }); + expect(data.type).toBe(SecureNoteType.Generic); + }); + + it("Convert from undefined", () => { + const data = new SecureNoteData(undefined); + expect(data.type).toBe(SecureNoteType.Generic); }); it("Convert", () => { const secureNote = new SecureNote(data); - expect(secureNote).toEqual({ - type: 0, - }); + expect(secureNote).toEqual({ type: 0 }); + expect(data.type).toBe(SecureNoteType.Generic); }); it("toSecureNoteData", () => { const secureNote = new SecureNote(data); expect(secureNote.toSecureNoteData()).toEqual(data); + expect(secureNote.toSecureNoteData().type).toBe(SecureNoteType.Generic); }); it("Decrypt", async () => { @@ -49,6 +54,14 @@ describe("SecureNote", () => { it("returns undefined if object is null", () => { expect(SecureNote.fromJSON(null)).toBeUndefined(); }); + + it("creates SecureNote instance from JSON object", () => { + const jsonObj = { type: SecureNoteType.Generic }; + const result = SecureNote.fromJSON(jsonObj); + + expect(result).toBeInstanceOf(SecureNote); + expect(result.type).toBe(SecureNoteType.Generic); + }); }); describe("toSdkSecureNote", () => { @@ -63,4 +76,71 @@ describe("SecureNote", () => { }); }); }); + + describe("fromSdkSecureNote", () => { + it("returns undefined when null is provided", () => { + const result = SecureNote.fromSdkSecureNote(null); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when undefined is provided", () => { + const result = SecureNote.fromSdkSecureNote(undefined); + + expect(result).toBeUndefined(); + }); + + it("creates SecureNote with Generic type from SDK object", () => { + const sdkSecureNote = { + type: SecureNoteType.Generic, + }; + + const result = SecureNote.fromSdkSecureNote(sdkSecureNote); + + expect(result).toBeInstanceOf(SecureNote); + expect(result.type).toBe(SecureNoteType.Generic); + }); + + it("preserves the type value from SDK object", () => { + const sdkSecureNote = { + type: SecureNoteType.Generic, + }; + + const result = SecureNote.fromSdkSecureNote(sdkSecureNote); + + expect(result.type).toBe(0); + }); + + it("creates a new SecureNote instance", () => { + const sdkSecureNote = { + type: SecureNoteType.Generic, + }; + + const result = SecureNote.fromSdkSecureNote(sdkSecureNote); + + expect(result).not.toBe(sdkSecureNote); + expect(result).toBeInstanceOf(SecureNote); + }); + + it("handles SDK object with undefined type", () => { + const sdkSecureNote = { + type: undefined as SecureNoteType, + }; + + const result = SecureNote.fromSdkSecureNote(sdkSecureNote); + + expect(result).toBeInstanceOf(SecureNote); + expect(result.type).toBeUndefined(); + }); + + it("returns symmetric with toSdkSecureNote", () => { + const original = new SecureNote(); + original.type = SecureNoteType.Generic; + + const sdkFormat = original.toSdkSecureNote(); + const reconstructed = SecureNote.fromSdkSecureNote(sdkFormat); + + expect(reconstructed.type).toBe(original.type); + }); + }); }); diff --git a/libs/common/src/vault/models/domain/ssh-key.spec.ts b/libs/common/src/vault/models/domain/ssh-key.spec.ts index 38228e54a4a..10149ebc82d 100644 --- a/libs/common/src/vault/models/domain/ssh-key.spec.ts +++ b/libs/common/src/vault/models/domain/ssh-key.spec.ts @@ -1,4 +1,5 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { EncString as SdkEncString, SshKey as SdkSshKey } from "@bitwarden/sdk-internal"; import { mockEnc } from "../../../../spec"; import { SshKeyApi } from "../api/ssh-key.api"; @@ -37,6 +38,9 @@ describe("Sshkey", () => { expect(sshKey.privateKey).toBeInstanceOf(EncString); expect(sshKey.publicKey).toBeInstanceOf(EncString); expect(sshKey.keyFingerprint).toBeInstanceOf(EncString); + expect(data.privateKey).toBeUndefined(); + expect(data.publicKey).toBeUndefined(); + expect(data.keyFingerprint).toBeUndefined(); }); it("toSshKeyData", () => { @@ -64,6 +68,21 @@ describe("Sshkey", () => { it("returns undefined if object is null", () => { expect(SshKey.fromJSON(null)).toBeUndefined(); }); + + it("creates SshKey instance from JSON object", () => { + const jsonObj = { + privateKey: "2.privateKey|encryptedData", + publicKey: "2.publicKey|encryptedData", + keyFingerprint: "2.keyFingerprint|encryptedData", + }; + + const result = SshKey.fromJSON(jsonObj); + + expect(result).toBeInstanceOf(SshKey); + expect(result.privateKey).toBeDefined(); + expect(result.publicKey).toBeDefined(); + expect(result.keyFingerprint).toBeDefined(); + }); }); describe("toSdkSshKey", () => { @@ -78,4 +97,58 @@ describe("Sshkey", () => { }); }); }); + + describe("fromSdkSshKey", () => { + it("returns undefined when null is provided", () => { + const result = SshKey.fromSdkSshKey(null); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when undefined is provided", () => { + const result = SshKey.fromSdkSshKey(undefined); + + expect(result).toBeUndefined(); + }); + + it("creates SshKey from SDK object", () => { + const sdkSshKey: SdkSshKey = { + privateKey: "2.privateKey|encryptedData" as SdkEncString, + publicKey: "2.publicKey|encryptedData" as SdkEncString, + fingerprint: "2.keyFingerprint|encryptedData" as SdkEncString, + }; + + const result = SshKey.fromSdkSshKey(sdkSshKey); + + expect(result).toBeInstanceOf(SshKey); + expect(result.privateKey).toBeDefined(); + expect(result.publicKey).toBeDefined(); + expect(result.keyFingerprint).toBeDefined(); + }); + + it("creates a new SshKey instance", () => { + const sdkSshKey: SdkSshKey = { + privateKey: "2.privateKey|encryptedData" as SdkEncString, + publicKey: "2.publicKey|encryptedData" as SdkEncString, + fingerprint: "2.keyFingerprint|encryptedData" as SdkEncString, + }; + + const result = SshKey.fromSdkSshKey(sdkSshKey); + + expect(result).not.toBe(sdkSshKey); + expect(result).toBeInstanceOf(SshKey); + }); + + it("is symmetric with toSdkSshKey", () => { + const original = new SshKey(data); + const sdkFormat = original.toSdkSshKey(); + const reconstructed = SshKey.fromSdkSshKey(sdkFormat); + + expect(reconstructed.privateKey.encryptedString).toBe(original.privateKey.encryptedString); + expect(reconstructed.publicKey.encryptedString).toBe(original.publicKey.encryptedString); + expect(reconstructed.keyFingerprint.encryptedString).toBe( + original.keyFingerprint.encryptedString, + ); + }); + }); }); diff --git a/libs/components/src/anon-layout/anon-layout.stories.ts b/libs/components/src/anon-layout/anon-layout.stories.ts index 3994fc28854..fb3bacb838f 100644 --- a/libs/components/src/anon-layout/anon-layout.stories.ts +++ b/libs/components/src/anon-layout/anon-layout.stories.ts @@ -77,20 +77,20 @@ export default { [hideBackgroundIllustration]="hideBackgroundIllustration" > -
    Thin Content
    +
    Thin Content
    -
    Long Content
    +
    Long Content
    Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
    Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
    -
    Normal Content
    +
    Normal Content
    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
    -
    +
    Secondary Projected Content (optional)
    diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts index 38f85bd7b1e..2ba85e32772 100644 --- a/libs/components/src/avatar/avatar.component.ts +++ b/libs/components/src/avatar/avatar.component.ts @@ -41,7 +41,7 @@ const SizeClasses: Record = { [attr.fill]="textColor()" [style.fontWeight]="svgFontWeight" [style.fontSize.px]="svgFontSize" - font-family='Roboto,"Helvetica Neue",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"' + font-family='Inter,"Helvetica Neue",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"' > {{ displayChars() }} diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index 6ef5309b018..0e50ccbe87a 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -69,7 +69,7 @@ const buttonStyles: Record = { export class ButtonComponent implements ButtonLikeAbstraction { @HostBinding("class") get classList() { return [ - "tw-font-semibold", + "tw-font-medium", "tw-rounded-full", "tw-transition", "tw-border-2", diff --git a/libs/components/src/callout/callout.component.html b/libs/components/src/callout/callout.component.html index e0fe0a182ea..303d09dfcf3 100644 --- a/libs/components/src/callout/callout.component.html +++ b/libs/components/src/callout/callout.component.html @@ -15,7 +15,7 @@ }
    @if (title) { -
    +
    {{ title }}
    } diff --git a/libs/components/src/color-password/color-password.stories.ts b/libs/components/src/color-password/color-password.stories.ts index 5a544dcb22e..65b6a3c0f18 100644 --- a/libs/components/src/color-password/color-password.stories.ts +++ b/libs/components/src/color-password/color-password.stories.ts @@ -4,7 +4,7 @@ import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for import { ColorPasswordComponent } from "./color-password.component"; -const examplePassword = "Wq$JkšŸ˜€7j DX#rS5Sdi!z "; +const examplePassword = "Wq$JkšŸ˜€7jlI DX#rS5Sdi!z "; export default { title: "Component Library/Color Password", diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index 9887c0bde8b..ca0e3fb1de5 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -126,7 +126,7 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE @HostBinding("class") get classList() { return [ - "tw-font-semibold", + "tw-font-medium", "tw-leading-[0px]", "tw-border-none", "tw-transition", diff --git a/libs/components/src/link/link.directive.ts b/libs/components/src/link/link.directive.ts index e6de8ac8402..df124a6811d 100644 --- a/libs/components/src/link/link.directive.ts +++ b/libs/components/src/link/link.directive.ts @@ -25,7 +25,7 @@ const commonStyles = [ "tw-leading-none", "tw-px-0", "tw-py-0.5", - "tw-font-semibold", + "tw-font-medium", "tw-bg-transparent", "tw-border-0", "tw-border-none", diff --git a/libs/components/src/navigation/nav-item.component.html b/libs/components/src/navigation/nav-item.component.html index 37a5d82aa1b..10f68145a4d 100644 --- a/libs/components/src/navigation/nav-item.component.html +++ b/libs/components/src/navigation/nav-item.component.html @@ -17,7 +17,7 @@
    -

    +

    diff --git a/libs/components/src/popover/popover.component.html b/libs/components/src/popover/popover.component.html index 756ac27b749..7fd210fcb00 100644 --- a/libs/components/src/popover/popover.component.html +++ b/libs/components/src/popover/popover.component.html @@ -12,7 +12,7 @@ class="tw-relative tw-z-20 tw-w-72 tw-break-words tw-bg-primary-100 tw-pb-4 tw-pt-2 tw-text-main" >

    -

    +

    {{ title() }}

    - Good (Tailwind) + Good (Tailwind) ```html
    ``` @@ -77,7 +77,7 @@ without this prefix, it probably shouldn't be there. **Exception:** Icon font classes, prefixed with `bwi`, are allowed.
    - Good (Icons) + Good (Icons) ```html ``` @@ -91,7 +91,7 @@ reactive forms to make use of these components. Review the [form component docs](?path=/docs/component-library-form--docs).
    - Bad + Bad ```html ... @@ -100,7 +100,7 @@ reactive forms to make use of these components. Review the
    - Good + Good ```html ... diff --git a/libs/components/src/table/sortable.component.ts b/libs/components/src/table/sortable.component.ts index b46c1ee9fbd..dfbaa0b00c3 100644 --- a/libs/components/src/table/sortable.component.ts +++ b/libs/components/src/table/sortable.component.ts @@ -106,7 +106,7 @@ export class SortableComponent implements OnInit { get classList() { return [ "tw-min-w-max", - "tw-font-bold", + "tw-font-medium", // Below is copied from BitIconButtonComponent "tw-border", diff --git a/libs/components/src/table/table-scroll.component.html b/libs/components/src/table/table-scroll.component.html index 99a5d200aca..2f9c0dcc533 100644 --- a/libs/components/src/table/table-scroll.component.html +++ b/libs/components/src/table/table-scroll.component.html @@ -5,7 +5,7 @@ > diff --git a/libs/components/src/table/table.component.html b/libs/components/src/table/table.component.html index 75d283e7bf3..e1a9d3311ab 100644 --- a/libs/components/src/table/table.component.html +++ b/libs/components/src/table/table.component.html @@ -1,6 +1,6 @@
    diff --git a/libs/components/src/tabs/shared/tab-list-item.directive.ts b/libs/components/src/tabs/shared/tab-list-item.directive.ts index bc70fdf6e4b..3a82c4083d9 100644 --- a/libs/components/src/tabs/shared/tab-list-item.directive.ts +++ b/libs/components/src/tabs/shared/tab-list-item.directive.ts @@ -60,7 +60,7 @@ export class TabListItemDirective implements FocusableOption { "tw-relative", "tw-py-2", "tw-px-4", - "tw-font-semibold", + "tw-font-medium", "tw-transition", "tw-rounded-t-lg", "tw-border-0", diff --git a/libs/components/src/theme.css b/libs/components/src/theme.css index f9ccc774d43..2aefe37c020 100644 --- a/libs/components/src/theme.css +++ b/libs/components/src/theme.css @@ -7,10 +7,10 @@ * Font faces */ @font-face { - font-family: Roboto; + font-family: Inter; src: - url("~@bitwarden/components/src/webfonts/roboto.woff2") format("woff2 supports variations"), - url("~@bitwarden/components/src/webfonts/roboto.woff2") format("woff2-variations"); + url("~@bitwarden/components/src/webfonts/inter.woff2") format("woff2 supports variations"), + url("~@bitwarden/components/src/webfonts/inter.woff2") format("woff2-variations"); font-display: swap; font-weight: 100 900; } @@ -20,7 +20,7 @@ */ :root { --font-sans: - Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + Inter, "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; --font-mono: Menlo, SFMono-Regular, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; diff --git a/libs/components/src/toast/toast.component.html b/libs/components/src/toast/toast.component.html index 36d58dcdda7..ec9ace01108 100644 --- a/libs/components/src/toast/toast.component.html +++ b/libs/components/src/toast/toast.component.html @@ -9,7 +9,7 @@
    {{ variant() | i18n }} @if (title(); as title) { -

    {{ title }}

    +

    {{ title }}

    } @for (m of messageArray; track m) {

    diff --git a/libs/components/src/toggle-group/toggle.component.ts b/libs/components/src/toggle-group/toggle.component.ts index 62e886ca572..538b31f6a32 100644 --- a/libs/components/src/toggle-group/toggle.component.ts +++ b/libs/components/src/toggle-group/toggle.component.ts @@ -56,7 +56,7 @@ export class ToggleComponent implements AfterContentChecked, AfterViewIn "tw-items-center", "tw-justify-center", "tw-gap-1.5", - "!tw-font-semibold", + "!tw-font-medium", "tw-leading-5", "tw-transition", "tw-text-center", diff --git a/libs/components/src/tw-theme-preflight.css b/libs/components/src/tw-theme-preflight.css index 372c80e0881..52287611c37 100644 --- a/libs/components/src/tw-theme-preflight.css +++ b/libs/components/src/tw-theme-preflight.css @@ -14,22 +14,22 @@ } h1 { - @apply tw-text-3xl tw-font-semibold tw-text-main tw-mb-2; + @apply tw-text-3xl tw-text-main tw-mb-2; } h2 { - @apply tw-text-2xl tw-font-semibold tw-text-main tw-mb-2; + @apply tw-text-2xl tw-text-main tw-mb-2; } h3 { - @apply tw-text-xl tw-font-semibold tw-text-main tw-mb-2; + @apply tw-text-xl tw-text-main tw-mb-2; } h4 { - @apply tw-text-lg tw-font-semibold tw-text-main tw-mb-2; + @apply tw-text-lg tw-text-main tw-mb-2; } h5 { - @apply tw-text-base tw-font-bold tw-text-main tw-mb-1.5; + @apply tw-text-base tw-text-main tw-mb-1.5; } h6 { - @apply tw-text-sm tw-font-bold tw-text-main tw-mb-1.5; + @apply tw-text-sm tw-text-main tw-mb-1.5; } code { @@ -59,7 +59,7 @@ } dt { - @apply tw-font-bold; + @apply tw-font-medium; } hr { @@ -78,4 +78,8 @@ svg { display: inline; } + + th { + @apply tw-font-medium; + } } diff --git a/libs/components/src/typography/typography.directive.ts b/libs/components/src/typography/typography.directive.ts index 3a507d3fc4b..b92e2e3cb4e 100644 --- a/libs/components/src/typography/typography.directive.ts +++ b/libs/components/src/typography/typography.directive.ts @@ -3,12 +3,12 @@ import { booleanAttribute, Directive, HostBinding, input } from "@angular/core"; type TypographyType = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "body1" | "body2" | "helper"; const styles: Record = { - h1: ["!tw-text-3xl", "tw-font-semibold", "tw-text-main"], - h2: ["!tw-text-2xl", "tw-font-semibold", "tw-text-main"], - h3: ["!tw-text-xl", "tw-font-semibold", "tw-text-main"], - h4: ["!tw-text-lg", "tw-font-semibold", "tw-text-main"], - h5: ["!tw-text-base", "tw-font-bold", "tw-text-main"], - h6: ["!tw-text-sm", "tw-font-bold", "tw-text-main"], + h1: ["!tw-text-3xl", "tw-text-main"], + h2: ["!tw-text-2xl", "tw-text-main"], + h3: ["!tw-text-xl", "tw-text-main"], + h4: ["!tw-text-lg", "tw-text-main"], + h5: ["!tw-text-base", "tw-text-main"], + h6: ["!tw-text-sm", "tw-text-main"], body1: ["!tw-text-base"], body2: ["!tw-text-sm"], helper: ["!tw-text-xs"], diff --git a/libs/components/src/typography/typography.stories.ts b/libs/components/src/typography/typography.stories.ts index 10fef6bbe72..bb055be79a0 100644 --- a/libs/components/src/typography/typography.stories.ts +++ b/libs/components/src/typography/typography.stories.ts @@ -1,28 +1,226 @@ -import { Meta, StoryObj } from "@storybook/angular"; +import { Meta, moduleMetadata } from "@storybook/angular"; + +import { TableModule, TableDataSource } from "../table"; import { TypographyDirective } from "./typography.directive"; export default { title: "Component Library/Typography", component: TypographyDirective, + decorators: [ + moduleMetadata({ + imports: [TableModule], + }), + ], } as Meta; -export const Default: StoryObj = { - render: (args) => ({ +type TypographyData = { + id: string; + typography: string; + classes?: string; + weight: string; + size: number; + lineHeight: string; +}; + +const typographyProps: TypographyData[] = [ + { + id: "h1", + typography: "h1", + weight: "Regular", + size: 30, + lineHeight: "150%", + }, + { + id: "h2", + typography: "h2", + weight: "Regular", + size: 24, + lineHeight: "150%", + }, + { + id: "h3", + typography: "h3", + weight: "Regular", + size: 20, + lineHeight: "150%", + }, + { + id: "h4", + typography: "h4", + weight: "Regular", + size: 18, + lineHeight: "150%", + }, + { + id: "h5", + typography: "h5", + weight: "Regular", + size: 16, + lineHeight: "150%", + }, + { + id: "h6", + typography: "h6", + weight: "Regular", + size: 14, + lineHeight: "150%", + }, + { + id: "body", + typography: "body1", + weight: "Regular", + size: 16, + lineHeight: "150%", + }, + { + id: "body-med", + typography: "body1", + classes: "tw-font-medium", + weight: "Medium", + size: 16, + lineHeight: "150%", + }, + { + id: "body-semi", + typography: "body1", + classes: "tw-font-semibold", + weight: "Semibold", + size: 16, + lineHeight: "150%", + }, + { + id: "body-underline", + typography: "body1", + classes: "tw-underline", + weight: "Regular", + size: 16, + lineHeight: "150%", + }, + { + id: "body-sm", + typography: "body2", + weight: "Regular", + size: 14, + lineHeight: "150%", + }, + { + id: "body-sm-med", + typography: "body2", + classes: "tw-font-medium", + weight: "Medium", + size: 14, + lineHeight: "150%", + }, + { + id: "body-sm-semi", + typography: "body2", + classes: "tw-font-semibold", + weight: "Semibold", + size: 14, + lineHeight: "150%", + }, + { + id: "body-sm-underline", + typography: "body2", + classes: "tw-underline", + weight: "Regular", + size: 14, + lineHeight: "150%", + }, + { + id: "helper", + typography: "helper", + weight: "Regular", + size: 12, + lineHeight: "150%", + }, + { + id: "helper-med", + typography: "helper", + classes: "tw-font-medium", + weight: "Medium", + size: 12, + lineHeight: "150%", + }, + { + id: "helper-semi", + typography: "helper", + classes: "tw-font-semibold", + weight: "Semibold", + size: 12, + lineHeight: "150%", + }, + { + id: "helper-underline", + typography: "helper", + classes: "tw-underline", + weight: "Regular", + size: 12, + lineHeight: "150%", + }, + { + id: "code", + typography: "body1", + classes: "tw-font-mono tw-text-code", + weight: "Regular", + size: 16, + lineHeight: "150%", + }, + { + id: "code-sm", + typography: "body2", + classes: "tw-font-mono tw-text-code", + weight: "Regular", + size: 14, + lineHeight: "150%", + }, + + { + id: "code-helper", + typography: "helper", + classes: "tw-font-mono tw-text-code", + weight: "Regular", + size: 12, + lineHeight: "150%", + }, +]; + +const typographyData = new TableDataSource(); +typographyData.data = typographyProps; + +export const Default = { + render: (args: { text: string; dataSource: typeof typographyProps }) => ({ props: args, template: /*html*/ ` -

    h1 - {{ text }}
    -
    h2 - {{ text }}
    -
    h3 - {{ text }}
    -
    h4 - {{ text }}
    -
    h5 - {{ text }}
    -
    h6 - {{ text }}
    -
    body1 - {{ text }}
    -
    body2 - {{ text }}
    -
    helper - {{ text }}
    + + +
    + + + + + + + + + + @for (row of rows$ | async; track row.id) { + + + + + + + + + } + + `, }), args: { text: `Sphinx of black quartz, judge my vow.`, + dataSource: typographyData, }, }; diff --git a/libs/components/src/webfonts/inter.woff2 b/libs/components/src/webfonts/inter.woff2 new file mode 100644 index 00000000000..5a8d3e72ad7 Binary files /dev/null and b/libs/components/src/webfonts/inter.woff2 differ diff --git a/libs/components/src/webfonts/roboto.woff2 b/libs/components/src/webfonts/roboto.woff2 deleted file mode 100644 index af8d2343d9c..00000000000 Binary files a/libs/components/src/webfonts/roboto.woff2 and /dev/null differ diff --git a/libs/components/tailwind.config.base.js b/libs/components/tailwind.config.base.js index 06c325894df..ce399d860c1 100644 --- a/libs/components/tailwind.config.base.js +++ b/libs/components/tailwind.config.base.js @@ -155,8 +155,14 @@ module.exports = { "90vw": "90vw", }), fontSize: { - xs: [".8125rem", "1rem"], - "3xl": ["1.75rem", "2rem"], + "3xl": ["1.875rem", "150%"], + "2xl": ["1.5rem", "150%"], + xl: ["1.25rem", "150%"], + lg: ["1.125rem", "150%"], + md: ["1rem", "150%"], + base: ["1rem", "150%"], + sm: ["0.875rem", "150%"], + xs: [".75rem", "150%"], }, container: { "@5xl": "1100px", diff --git a/libs/importer/src/components/import.component.html b/libs/importer/src/components/import.component.html index 3bd4b741dbb..bd4afaf364b 100644 --- a/libs/importer/src/components/import.component.html +++ b/libs/importer/src/components/import.component.html @@ -11,7 +11,7 @@ -

    {{ "destination" | i18n }}

    +

    {{ "destination" | i18n }}

    @@ -62,7 +62,7 @@ -

    {{ "data" | i18n }}

    +

    {{ "data" | i18n }}

    @@ -70,7 +70,7 @@ } @else {
    -

    {{ "keyConnectorDomain" | i18n }}:

    +

    {{ "keyConnectorDomain" | i18n }}:

    {{ keyConnectorUrl }}

    diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 1c72f5f3230..42d7f5aaaf8 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -40,6 +40,7 @@ export const AUTO_CONFIRM = new StateDefinition("autoConfirm", "disk"); // Billing export const BILLING_DISK = new StateDefinition("billing", "disk"); +export const BILLING_MEMORY = new StateDefinition("billing", "memory"); // Auth @@ -107,6 +108,10 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne web: "disk-local", }); +// DIRT + +export const PHISHING_DETECTION_DISK = new StateDefinition("phishingDetection", "disk"); + // Platform export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", { diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html index 1d5629cfd48..bcced7e012b 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html @@ -3,19 +3,11 @@ {{ (hideIcon ? "createSend" : "new") | i18n }} - + {{ "sendTypeText" | i18n }} - +
    {{ "sendTypeFile" | i18n }} diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts index 1ffd9644208..e1474175267 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; -import { RouterLink } from "@angular/router"; +import { Router, RouterLink } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; @@ -8,6 +8,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -32,6 +33,8 @@ export class NewSendDropdownComponent implements OnInit { constructor( private billingAccountProfileStateService: BillingAccountProfileStateService, private accountService: AccountService, + private router: Router, + private premiumUpgradePromptService: PremiumUpgradePromptService, ) {} async ngOnInit() { @@ -46,18 +49,21 @@ export class NewSendDropdownComponent implements OnInit { )); } - buildRouterLink(type: SendType) { - if (this.hasNoPremium && type === SendType.File) { - return "/premium"; - } else { - return "/add-send"; - } + buildRouterLink() { + return "/add-send"; } buildQueryParams(type: SendType) { - if (this.hasNoPremium && type === SendType.File) { - return null; - } return { type: type, isNew: true }; } + + async sendFileClick() { + if (this.hasNoPremium) { + await this.premiumUpgradePromptService.promptForPremium(); + } else { + await this.router.navigate([this.buildRouterLink()], { + queryParams: this.buildQueryParams(SendType.File), + }); + } + } } diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html index 3442375315a..2ece050e8c3 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html @@ -1,6 +1,6 @@ -

    +

    {{ headerText }}

    {{ sends.length }} diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts index 3aeeac6ca92..f1bb1ef942b 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts @@ -201,12 +201,12 @@ describe("AutofillOptionsComponent", () => { it("updates the default autofill on page load label", () => { fixture.detectChanges(); - expect(component["autofillOptions"][0].label).toEqual("defaultLabel no"); + expect(component["autofillOptions"][0].label).toEqual("defaultLabelWithValue no"); (autofillSettingsService.autofillOnPageLoadDefault$ as BehaviorSubject).next(true); fixture.detectChanges(); - expect(component["autofillOptions"][0].label).toEqual("defaultLabel yes"); + expect(component["autofillOptions"][0].label).toEqual("defaultLabelWithValue yes"); }); it("hides the autofill on page load field when the setting is disabled", () => { diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts index e6b8b5c9aca..7215b1d6c67 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts @@ -218,7 +218,10 @@ export class AutofillOptionsComponent implements OnInit { return; } - this.autofillOptions[0].label = this.i18nService.t("defaultLabel", defaultOption.label); + this.autofillOptions[0].label = this.i18nService.t( + "defaultLabelWithValue", + defaultOption.label, + ); // Trigger change detection to update the label in the template this.autofillOptions = [...this.autofillOptions]; }); diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts index 2d06f5dcc29..ed70b4381d2 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts @@ -77,19 +77,19 @@ describe("UriOptionComponent", () => { component.defaultMatchDetection = UriMatchStrategy.Domain; fixture.detectChanges(); - expect(component["uriMatchOptions"][0].label).toBe("defaultLabel baseDomain"); + expect(component["uriMatchOptions"][0].label).toBe("defaultLabelWithValue baseDomain"); }); it("should update the default uri match strategy label", () => { component.defaultMatchDetection = UriMatchStrategy.Exact; fixture.detectChanges(); - expect(component["uriMatchOptions"][0].label).toBe("defaultLabel exact"); + expect(component["uriMatchOptions"][0].label).toBe("defaultLabelWithValue exact"); component.defaultMatchDetection = UriMatchStrategy.StartsWith; fixture.detectChanges(); - expect(component["uriMatchOptions"][0].label).toBe("defaultLabel startsWith"); + expect(component["uriMatchOptions"][0].label).toBe("defaultLabelWithValue startsWith"); }); it("should focus the uri input when focusInput is called", () => { diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts index b61109a45bb..34ac284c3f3 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts @@ -124,7 +124,7 @@ export class UriOptionComponent implements ControlValueAccessor { } this.uriMatchOptions[0].label = this.i18nService.t( - "defaultLabel", + "defaultLabelWithValue", this.uriMatchOptions.find((o) => o.value === value)?.label, ); } diff --git a/package-lock.json b/package-lock.json index da21f9ca730..e456e257ca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,8 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.365", - "@bitwarden/sdk-internal": "0.2.0-main.365", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.369", + "@bitwarden/sdk-internal": "0.2.0-main.369", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -134,7 +134,7 @@ "copy-webpack-plugin": "13.0.0", "cross-env": "10.1.0", "css-loader": "7.1.2", - "electron": "36.9.3", + "electron": "37.7.0", "electron-builder": "26.0.12", "electron-log": "5.4.0", "electron-reload": "2.0.0-alpha.1", @@ -152,7 +152,7 @@ "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.3", "husky": "9.1.7", - "jest-diff": "29.7.0", + "jest-diff": "30.2.0", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.7", "jest-preset-angular": "14.6.1", @@ -194,11 +194,11 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2025.10.1" + "version": "2025.11.0" }, "apps/cli": { "name": "@bitwarden/cli", - "version": "2025.10.1", + "version": "2025.11.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@koa/multer": "4.0.0", @@ -280,7 +280,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.10.2", + "version": "2025.11.0", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -294,7 +294,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2025.10.1" + "version": "2025.11.0" }, "libs/admin-console": { "name": "@bitwarden/admin-console", @@ -4607,9 +4607,9 @@ "link": true }, "node_modules/@bitwarden/commercial-sdk-internal": { - "version": "0.2.0-main.365", - "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.365.tgz", - "integrity": "sha512-yRc2k29rKMxss6qH2TP91VcE6tNR6/A2ASZMj+Om2MEaanV82zcx89dkShh6RP0jXICM+c/m6BgGkmu+1Pcp8w==", + "version": "0.2.0-main.369", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.369.tgz", + "integrity": "sha512-O+EaPQJQah9j3yWzgw+dwFk5iOxPXdKf1FDeykbt+cxygSYbWTR60RXenG1LysknOdy8fiTfHEaPD+LP1LxrdA==", "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", "dependencies": { "type-fest": "^4.41.0" @@ -4712,9 +4712,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.365", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.365.tgz", - "integrity": "sha512-x0sqAuyknFOGf5ZfbuFTxL0olMiGyyLbJ10tXCYHnrkjdspdNm2BGZc64NQgXz5h+PH1Uwtow/01o/a4F0YTHw==", + "version": "0.2.0-main.369", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.369.tgz", + "integrity": "sha512-gyp4Wd1YbkANA0/RNxHfVk+DuiJqxItzk/YUyQ2HsLeP07xOljftmA0XspLQz59ovs7e1jHMCpH1r/XcyKiQSw==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" @@ -20865,9 +20865,9 @@ } }, "node_modules/electron": { - "version": "36.9.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-36.9.3.tgz", - "integrity": "sha512-eR5yswsA55zVTPDEIA/PSdVNBLOp0q0Wsavgx0S3BmJYOqKoH1gqzS+hggf0/aY5OvUjVNSHiJJA1VsB5aJUug==", + "version": "37.7.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-37.7.0.tgz", + "integrity": "sha512-LBzvfrS0aalynOsnC11AD7zeoU8eOois090mzLpQM3K8yZ2N04i2ZW9qmHOTFLrXlKvrwRc7EbyQf1u8XHMl6Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -26136,26 +26136,51 @@ "license": "MIT" }, "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-diff/node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "license": "MIT" + }, "node_modules/jest-diff/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -26165,25 +26190,23 @@ } }, "node_modules/jest-diff/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-diff/node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, "license": "MIT" }, "node_modules/jest-docblock": { @@ -26658,6 +26681,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-matcher-utils/node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-matcher-utils/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -27225,6 +27264,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-snapshot/node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-snapshot/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -32171,36 +32226,6 @@ } } }, - "node_modules/nx/node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/nx/node_modules/@sinclair/typebox": { - "version": "0.34.38", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", - "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", - "license": "MIT" - }, - "node_modules/nx/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/nx/node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -32258,21 +32283,6 @@ "node": ">=8" } }, - "node_modules/nx/node_modules/jest-diff": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.5.tgz", - "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==", - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.0.1", - "chalk": "^4.1.2", - "pretty-format": "30.0.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/nx/node_modules/jsonc-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", @@ -32318,26 +32328,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/nx/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/nx/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/nx/node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", diff --git a/package.json b/package.json index 31d7f424569..e224fd00213 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "copy-webpack-plugin": "13.0.0", "cross-env": "10.1.0", "css-loader": "7.1.2", - "electron": "36.9.3", + "electron": "37.7.0", "electron-builder": "26.0.12", "electron-log": "5.4.0", "electron-reload": "2.0.0-alpha.1", @@ -115,7 +115,7 @@ "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.3", "husky": "9.1.7", - "jest-diff": "29.7.0", + "jest-diff": "30.2.0", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.7", "jest-preset-angular": "14.6.1", @@ -160,8 +160,8 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.365", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.365", + "@bitwarden/sdk-internal": "0.2.0-main.369", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.369", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0",
    Rendered TextbitTypography VariantAdditional ClassesWeightSizeLine Height
    {{text}}
    {{row.typography}}{{row.classes}}{{row.weight}}{{row.size}}{{row.lineHeight}}