1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-01 09:13:54 +00:00

Merge branch 'main' into auth/pm-26209/bugfix-desktop-error-on-auth-request-approval

This commit is contained in:
rr-bw
2025-11-04 11:29:52 -08:00
219 changed files with 4998 additions and 1567 deletions

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
<!-- preload the inter font to avoid a flash of fallback font when first loading storybook -->
<!-- href matches the inter build artifact from webpack -->
<link
rel="preload"
href="/inter.0336a89fb4e7fc1d.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<!-- load inter font from the source cdn so that chromatic snapshots render in the correct font -->
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<!-- manually specify inter as the font here for chromatic snapshots -->
<style>
:root {
font-family: Inter;
font-weight: 100 900;
}
</style>

View File

@@ -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",

View File

@@ -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."

View File

@@ -68,7 +68,7 @@ const actionButtonStyles = ({
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
font-weight: 700;
font-weight: 500;
${disabled || isLoading
? `

View File

@@ -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;
`,
};

View File

@@ -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;
`;

View File

@@ -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;
`;

View File

@@ -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;
`;

View File

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

View File

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

View File

@@ -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({

View File

@@ -1,5 +1,4 @@
<!-- eslint-disable tailwindcss/no-custom-classname -->
<!doctype html>
<!doctype html>
<html>
<head>
<title>Bitwarden</title>

View File

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

View File

@@ -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 = `<iframe id="subframe" src="https://example.com/"></iframe>`;
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 = `<iframe id="subframe" src="https://example.com/"></iframe>`;
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<

View File

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

View File

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

View File

@@ -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);

View File

@@ -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<ApiService>;
let taskSchedulerService: TaskSchedulerService;
let logService: MockProxy<LogService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
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<ApiService>();
logService = mock<LogService>();
platformUtilsService = mock<PlatformUtilsService>();
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");
});
});
});

View File

@@ -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<PhishingData>(
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<void>();
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<boolean> {
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<PhishingData | null> {
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 [];
}
}

View File

@@ -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
});

View File

@@ -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<void>();
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<PhishingDetectionNavigationEvent>();
private static _navigationEvents: Subscription | null = null;
private static _knownPhishingDomains = new Set<string>();
private static _caughtTabs: Map<PhishingDetectionTabId, CaughtPhishingDomain> = 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<void> {
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<void> {
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<void>();
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

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -8,7 +8,7 @@
<li *ngFor="let button of navButtons" class="tw-flex-1 tw-list-none tw-relative">
<button
class="tw-w-full tw-flex tw-flex-col tw-items-center tw-px-0.5 tw-py-2 bit-compact:tw-py-1 tw-bg-transparent tw-no-underline hover:tw-no-underline hover:tw-text-primary-600 tw-group/tab-nav-btn hover:tw-bg-hover-default tw-border-2 tw-border-solid tw-border-transparent focus-visible:tw-rounded-lg focus-visible:tw-border-primary-600"
[ngClass]="rla.isActive ? 'tw-font-bold tw-text-primary-600' : 'tw-text-muted'"
[ngClass]="rla.isActive ? 'tw-font-medium tw-text-primary-600' : 'tw-text-muted'"
title="{{ button.label | i18n }}"
[routerLink]="button.page"
[appA11yTitle]="buttonTitle(button)"

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100% 100%">
<text fill="%23333333" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
<text fill="%23333333" x="50%" y="50%" font-family="\'Inter\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
font-size="18" text-anchor="middle">
Loading...
</text>

View File

@@ -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-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
$font-size-base: 16px;
$font-size-large: 18px;

View File

@@ -17,7 +17,7 @@
<div class="tw-size-[95px] tw-content-center">
<bit-icon [icon]="sendCreatedIcon"></bit-icon>
</div>
<h3 tabindex="0" appAutofocus class="tw-font-semibold">
<h3 tabindex="0" appAutofocus class="tw-font-medium">
{{ "createdSendSuccessfully" | i18n }}
</h3>
<p class="tw-text-center">

View File

@@ -155,11 +155,12 @@ describe("OpenAttachmentsComponent", () => {
});
it("routes the user to the premium page when they cannot access premium features", async () => {
const premiumUpgradeService = TestBed.inject(PremiumUpgradePromptService);
hasPremiumFromAnySource$.next(false);
await component.openAttachments();
expect(router.navigate).toHaveBeenCalledWith(["/premium"]);
expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled();
});
it("disables attachments when the edit form is disabled", () => {

View File

@@ -19,6 +19,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { BadgeModule, ItemModule, ToastService, TypographyModule } from "@bitwarden/components";
import { CipherFormContainer } from "@bitwarden/vault";
@@ -67,6 +68,7 @@ export class OpenAttachmentsComponent implements OnInit {
private filePopoutUtilsService: FilePopoutUtilsService,
private accountService: AccountService,
private cipherFormContainer: CipherFormContainer,
private premiumUpgradeService: PremiumUpgradePromptService,
) {
this.accountService.activeAccount$
.pipe(
@@ -115,7 +117,7 @@ export class OpenAttachmentsComponent implements OnInit {
/** Routes the user to the attachments screen, if available */
async openAttachments() {
if (!this.canAccessAttachments) {
await this.router.navigate(["/premium"]);
await this.premiumUpgradeService.promptForPremium();
return;
}

View File

@@ -181,10 +181,21 @@ describe("ItemMoreOptionsComponent", () => {
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
it("does not show the exact match dialog when the default match strategy is Exact and autofill confirmation is not to be shown", async () => {
// autofill confirmation dialog is not shown when either the feature flag is disabled or search text is not present
uriMatchStrategy$.next(UriMatchStrategy.Exact);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" });
await component.doAutofill();
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
});
describe("autofill confirmation dialog", () => {
beforeEach(() => {
// autofill confirmation dialog is shown when feature flag is enabled and search text is present
featureFlag$.next(true);
hasSearchText$.next(true);
uriMatchStrategy$.next(UriMatchStrategy.Domain);
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true);
});
@@ -243,47 +254,122 @@ describe("ItemMoreOptionsComponent", () => {
});
describe("URI match strategy handling", () => {
it("shows the exact match dialog when the uri match strategy is Exact", async () => {
uriMatchStrategy$.next(UriMatchStrategy.Exact);
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
describe("when the default URI match strategy is Exact", () => {
beforeEach(() => {
uriMatchStrategy$.next(UriMatchStrategy.Exact);
});
await component.doAutofill();
it("shows the exact match dialog and not the password dialog", async () => {
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.objectContaining({ key: "cannotAutofill" }),
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
type: "info",
}),
);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
await component.doAutofill();
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.objectContaining({ key: "cannotAutofill" }),
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
type: "info",
}),
);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(passwordRepromptService.passwordRepromptCheck).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
});
it("shows the exact match dialog and not the password reprompt dialog when the uri match strategy is Exact and the item has master password reprompt enabled", async () => {
uriMatchStrategy$.next(UriMatchStrategy.Exact);
describe("when the default URI match strategy is not Exact", () => {
beforeEach(() => {
mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
uriMatchStrategy$.next(UriMatchStrategy.Domain);
});
it("does not show the exact match dialog", async () => {
cipherService.getFullCipherView.mockImplementation(async (c) => ({
...baseCipher,
...c,
login: {
...baseCipher.login,
uris: [
{ uri: "https://one.example.com", match: UriMatchStrategy.Exact },
{ uri: "https://page.example.com", match: UriMatchStrategy.Domain },
],
},
}));
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
await component.doAutofill();
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
});
it("shows the exact match dialog when the cipher has a single uri with a match strategy of Exact", async () => {
cipherService.getFullCipherView.mockImplementation(async (c) => ({
...baseCipher,
...c,
login: {
...baseCipher.login,
uris: [{ uri: "https://one.example.com", match: UriMatchStrategy.Exact }],
},
}));
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
await component.doAutofill();
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.objectContaining({ key: "cannotAutofill" }),
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
type: "info",
}),
);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
});
it("does not show the exact match dialog when the cipher has no uris", async () => {
mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
cipherService.getFullCipherView.mockImplementation(async (c) => ({
...baseCipher,
...c,
login: {
...baseCipher.login,
uris: [],
},
}));
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
await component.doAutofill();
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.objectContaining({ key: "cannotAutofill" }),
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
type: "info",
}),
);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(passwordRepromptService.passwordRepromptCheck).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
});
it("does not show the exact match dialog when the cipher has a uri with a match strategy of Exact and a uri with a match strategy of Domain", async () => {
mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
cipherService.getFullCipherView.mockImplementation(async (c) => ({
...baseCipher,
...c,
login: {
...baseCipher.login,
uris: [
{ uri: "https://one.example.com", match: UriMatchStrategy.Exact },
{ uri: "https://page.example.com", match: UriMatchStrategy.Domain },
],
},
}));
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
await component.doAutofill();
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
});
});
it("hides the 'Fill and Save' button when showAutofillConfirmation$ is true", async () => {
// Enable both feature flag and search text → makes showAutofillConfirmation$ true
fixture.detectChanges();
await fixture.whenStable();

View File

@@ -202,8 +202,17 @@ export class ItemMoreOptionsComponent {
async doAutofill() {
const cipher = await this.cipherService.getFullCipherView(this.cipher);
const uris = cipher.login?.uris ?? [];
const cipherHasAllExactMatchLoginUris =
uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact);
const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$);
const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$);
if (uriMatchStrategy === UriMatchStrategy.Exact) {
if (
showAutofillConfirmation &&
(cipherHasAllExactMatchLoginUris || uriMatchStrategy === UriMatchStrategy.Exact)
) {
await this.dialogService.openSimpleDialog({
title: { key: "cannotAutofill" },
content: { key: "cannotAutofillExactMatch" },
@@ -218,8 +227,6 @@ export class ItemMoreOptionsComponent {
return;
}
const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$);
if (!showAutofillConfirmation) {
await this.vaultPopupAutofillService.doAutofill(cipher, true, true);
return;

View File

@@ -2,25 +2,69 @@ import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { BrowserPremiumUpgradePromptService } from "./browser-premium-upgrade-prompt.service";
describe("BrowserPremiumUpgradePromptService", () => {
let service: BrowserPremiumUpgradePromptService;
let router: MockProxy<Router>;
let configService: MockProxy<ConfigService>;
let dialogService: MockProxy<DialogService>;
beforeEach(async () => {
router = mock<Router>();
configService = mock<ConfigService>();
dialogService = mock<DialogService>();
await TestBed.configureTestingModule({
providers: [BrowserPremiumUpgradePromptService, { provide: Router, useValue: router }],
providers: [
BrowserPremiumUpgradePromptService,
{ provide: Router, useValue: router },
{ provide: ConfigService, useValue: configService },
{ provide: DialogService, useValue: dialogService },
],
}).compileComponents();
service = TestBed.inject(BrowserPremiumUpgradePromptService);
});
describe("promptForPremium", () => {
it("navigates to the premium update screen", async () => {
let openSpy: jest.SpyInstance;
beforeEach(() => {
openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation();
});
afterEach(() => {
openSpy.mockRestore();
});
it("opens the new premium upgrade dialog when feature flag is enabled", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
await service.promptForPremium();
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
expect(openSpy).toHaveBeenCalledWith(dialogService);
expect(router.navigate).not.toHaveBeenCalled();
});
it("navigates to the premium update screen when feature flag is disabled", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
await service.promptForPremium();
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
expect(router.navigate).toHaveBeenCalledWith(["/premium"]);
expect(openSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,18 +1,32 @@
import { inject } from "@angular/core";
import { Router } from "@angular/router";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { DialogService } from "@bitwarden/components";
/**
* This class handles the premium upgrade process for the browser extension.
*/
export class BrowserPremiumUpgradePromptService implements PremiumUpgradePromptService {
private router = inject(Router);
private configService = inject(ConfigService);
private dialogService = inject(DialogService);
async promptForPremium() {
/**
* Navigate to the premium update screen.
*/
await this.router.navigate(["/premium"]);
const showNewDialog = await this.configService.getFeatureFlag(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
if (showNewDialog) {
PremiumUpgradeDialogComponent.open(this.dialogService);
} else {
/**
* Navigate to the premium update screen.
*/
await this.router.navigate(["/premium"]);
}
}
}

View File

@@ -10,6 +10,7 @@ config.content = [
"../../libs/vault/src/**/*.{html,ts}",
"../../libs/angular/src/**/*.{html,ts}",
"../../libs/vault/src/**/*.{html,ts}",
"../../libs/pricing/src/**/*.{html,ts}",
];
module.exports = config;

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/cli",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.10.1",
"version": "2025.11.0",
"keywords": [
"bitwarden",
"password",

View File

@@ -454,7 +454,6 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"verifysign",
"windows 0.61.1",
]
@@ -621,6 +620,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"verifysign",
"windows 0.61.1",
]
@@ -900,19 +900,6 @@ dependencies = [
"syn",
]
[[package]]
name = "dashmap"
version = "5.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -1533,12 +1520,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.3"
@@ -1554,7 +1535,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.3",
"hashbrown",
]
[[package]]
@@ -1719,7 +1700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
"hashbrown 0.15.3",
"hashbrown",
]
[[package]]
@@ -1891,7 +1872,6 @@ version = "0.0.0"
dependencies = [
"desktop_core",
"futures",
"oslog",
"serde",
"serde_json",
"tokio",
@@ -2379,17 +2359,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "oslog"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969"
dependencies = [
"cc",
"dashmap",
"log",
]
[[package]]
name = "p256"
version = "0.13.2"

View File

@@ -41,13 +41,11 @@ interprocess = "=2.2.1"
keytar = "=0.1.6"
libc = "=0.2.172"
linux-keyutils = "=0.2.4"
log = "=0.4.25"
memsec = "=0.7.0"
napi = "=2.16.17"
napi-build = "=2.2.0"
napi-derive = "=2.16.13"
oo7 = "=0.4.3"
oslog = "=0.2.0"
pin-project = "=1.1.10"
pkcs8 = "=0.10.2"
rand = "=0.9.1"
@@ -60,7 +58,6 @@ security-framework-sys = "=2.15.0"
serde = "=1.0.209"
serde_json = "=1.0.127"
sha2 = "=0.10.8"
simplelog = "=0.12.2"
ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false }
sysinfo = "=0.35.0"
@@ -86,10 +83,13 @@ zbus_polkit = "=5.0.0"
zeroizing-alloc = "=0.1.0"
[workspace.lints.clippy]
disallowed-macros = "deny"
# Dis-allow println and eprintln, which are typically used in debugging.
# Use `tracing` and `tracing-subscriber` crates for observability needs.
print_stderr = "deny"
print_stdout = "deny"
string_slice = "warn"
unused_async = "deny"
unwrap_used = "deny"

View File

@@ -1,2 +1,10 @@
allow-unwrap-in-tests=true
allow-expect-in-tests=true
disallowed-macros = [
{ path = "log::trace", reason = "Use tracing for logging needs", replacement = "tracing::trace" },
{ path = "log::debug", reason = "Use tracing for logging needs", replacement = "tracing::debug" },
{ path = "log::info", reason = "Use tracing for logging needs", replacement = "tracing::info" },
{ path = "log::warn", reason = "Use tracing for logging needs", replacement = "tracing::warn" },
{ path = "log::error", reason = "Use tracing for logging needs", replacement = "tracing::error" },
]

View File

@@ -21,12 +21,11 @@ serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio-util = { workspace = true }
tracing = { workspace = true }
tracing-oslog = "0.3.0"
tracing-subscriber = { workspace = true }
uniffi = { workspace = true, features = ["cli"] }
[target.'cfg(target_os = "macos")'.dependencies]
oslog = { workspace = true }
tracing-oslog = "0.3.0"
[build-dependencies]
uniffi = { workspace = true, features = ["build"] }

View File

@@ -1,4 +1,5 @@
#![cfg(target_os = "macos")]
#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation
use std::{
collections::HashMap,

View File

@@ -20,7 +20,7 @@
"**/node_modules/@bitwarden/desktop-napi/index.js",
"**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node"
],
"electronVersion": "36.9.3",
"electronVersion": "37.7.0",
"generateUpdatesFilesForAllChannels": true,
"publish": {
"provider": "generic",

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.10.2",
"version": "2025.11.0",
"keywords": [
"bitwarden",
"password",

View File

@@ -132,7 +132,7 @@ export class AvatarComponent implements OnChanges, OnInit {
textTag.setAttribute("fill", Utils.pickTextColorBasedOnBgColor(color, 135, true));
textTag.setAttribute(
"font-family",
'Roboto,"Helvetica Neue",Helvetica,Arial,' +
'Inter,"Helvetica Neue",Helvetica,Arial,' +
'sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"',
);
textTag.textContent = character;

View File

@@ -19,14 +19,23 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
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 { CalloutModule, DialogService, ToastService } from "@bitwarden/components";
import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-send-add-edit",
templateUrl: "add-edit.component.html",
imports: [CommonModule, JslibModule, ReactiveFormsModule, CalloutModule],
providers: [
{
provide: PremiumUpgradePromptService,
useClass: DesktopPremiumUpgradePromptService,
},
],
})
export class AddEditComponent extends BaseAddEditComponent {
constructor(
@@ -45,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent {
billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
toastService: ToastService,
premiumUpgradePromptService: PremiumUpgradePromptService,
) {
super(
i18nService,
@@ -62,6 +72,7 @@ export class AddEditComponent extends BaseAddEditComponent {
billingAccountProfileStateService,
accountService,
toastService,
premiumUpgradePromptService,
);
}

View File

@@ -1,6 +1,6 @@
<form [bitSubmit]="submit" [formGroup]="setShortcutForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>
<div class="tw-font-medium" bitDialogTitle>
{{ "typeShortcut" | i18n }}
</div>
<div bitDialogContent>

View File

@@ -5,6 +5,7 @@ import { LogService } from "@bitwarden/logging";
import { WindowMain } from "../../main/window.main";
import { stringIsNotUndefinedNullAndEmpty } from "../../utils";
import { AutotypeVaultData } from "../models/autotype-vault-data";
import { AutotypeKeyboardShortcut } from "../models/main-autotype-keyboard-shortcut";
export class MainDesktopAutotypeService {
@@ -47,18 +48,12 @@ export class MainDesktopAutotypeService {
}
});
ipcMain.on("autofill.completeAutotypeRequest", (event, data) => {
const { response } = data;
ipcMain.on("autofill.completeAutotypeRequest", (_event, vaultData: AutotypeVaultData) => {
if (
stringIsNotUndefinedNullAndEmpty(response.username) &&
stringIsNotUndefinedNullAndEmpty(response.password)
stringIsNotUndefinedNullAndEmpty(vaultData.username) &&
stringIsNotUndefinedNullAndEmpty(vaultData.password)
) {
this.doAutotype(
response.username,
response.password,
this.autotypeKeyboardShortcut.getArrayFormat(),
);
this.doAutotype(vaultData, this.autotypeKeyboardShortcut.getArrayFormat());
}
});
}
@@ -89,8 +84,9 @@ export class MainDesktopAutotypeService {
: this.logService.info("Enabling autotype failed.");
}
private doAutotype(username: string, password: string, keyboardShortcut: string[]) {
const inputPattern = username + "\t" + password;
private doAutotype(vaultData: AutotypeVaultData, keyboardShortcut: string[]) {
const TAB = "\t";
const inputPattern = vaultData.username + TAB + vaultData.password;
const inputArray = new Array<number>(inputPattern.length);
for (let i = 0; i < inputPattern.length; i++) {

View File

@@ -0,0 +1,8 @@
/**
* Vault data used in autotype operations.
* `username` and `password` are guaranteed to be not null/undefined.
*/
export interface AutotypeVaultData {
username: string;
password: string;
}

View File

@@ -5,6 +5,8 @@ import type { autofill } from "@bitwarden/desktop-napi";
import { Command } from "../platform/main/autofill/command";
import { RunCommandParams, RunCommandResult } from "../platform/main/autofill/native-autofill.main";
import { AutotypeVaultData } from "./models/autotype-vault-data";
export default {
runCommand: <C extends Command>(params: RunCommandParams<C>): Promise<RunCommandResult<C>> =>
ipcRenderer.invoke("autofill.runCommand", params),
@@ -133,10 +135,7 @@ export default {
listenAutotypeRequest: (
fn: (
windowTitle: string,
completeCallback: (
error: Error | null,
response: { username?: string; password?: string },
) => void,
completeCallback: (error: Error | null, response: AutotypeVaultData | null) => void,
) => void,
) => {
ipcRenderer.on(
@@ -149,7 +148,7 @@ export default {
) => {
const { windowTitle } = data;
fn(windowTitle, (error, response) => {
fn(windowTitle, (error, vaultData) => {
if (error) {
ipcRenderer.send("autofill.completeError", {
windowTitle,
@@ -157,11 +156,9 @@ export default {
});
return;
}
ipcRenderer.send("autofill.completeAutotypeRequest", {
windowTitle,
response,
});
if (vaultData !== null) {
ipcRenderer.send("autofill.completeAutotypeRequest", vaultData);
}
});
},
);

View File

@@ -0,0 +1,50 @@
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { getAutotypeVaultData } from "./desktop-autotype.service";
describe("getAutotypeVaultData", () => {
it("should return vault data when cipher has username and password", () => {
const cipherView = new CipherView();
cipherView.login.username = "foo";
cipherView.login.password = "bar";
const [error, vaultData] = getAutotypeVaultData(cipherView);
expect(error).toBeNull();
expect(vaultData?.username).toEqual("foo");
expect(vaultData?.password).toEqual("bar");
});
it("should return error when firstCipher is undefined", () => {
const cipherView = undefined;
const [error, vaultData] = getAutotypeVaultData(cipherView);
expect(vaultData).toBeNull();
expect(error).toBeDefined();
expect(error?.message).toEqual("No matching vault item.");
});
it("should return error when username is undefined", () => {
const cipherView = new CipherView();
cipherView.login.username = undefined;
cipherView.login.password = "bar";
const [error, vaultData] = getAutotypeVaultData(cipherView);
expect(vaultData).toBeNull();
expect(error).toBeDefined();
expect(error?.message).toEqual("Vault item is undefined.");
});
it("should return error when password is undefined", () => {
const cipherView = new CipherView();
cipherView.login.username = "foo";
cipherView.login.password = undefined;
const [error, vaultData] = getAutotypeVaultData(cipherView);
expect(vaultData).toBeNull();
expect(error).toBeDefined();
expect(error?.message).toEqual("Vault item is undefined.");
});
});

View File

@@ -17,6 +17,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { UserId } from "@bitwarden/user-core";
import { AutotypeVaultData } from "../models/autotype-vault-data";
import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service";
export const defaultWindowsAutotypeKeyboardShortcut: string[] = ["Control", "Shift", "B"];
@@ -27,6 +29,8 @@ export const AUTOTYPE_ENABLED = new KeyDefinition<boolean | null>(
{ deserializer: (b) => b },
);
export type Result<T, E = Error> = [E, null] | [null, T];
/*
Valid windows shortcut keys: Control, Alt, Super, Shift, letters A - Z
Valid macOS shortcut keys: Control, Alt, Command, Shift, letters A - Z
@@ -63,11 +67,8 @@ export class DesktopAutotypeService {
ipc.autofill.listenAutotypeRequest(async (windowTitle, callback) => {
const possibleCiphers = await this.matchCiphersToWindowTitle(windowTitle);
const firstCipher = possibleCiphers?.at(0);
return callback(null, {
username: firstCipher?.login?.username,
password: firstCipher?.login?.password,
});
const [error, vaultData] = getAutotypeVaultData(firstCipher);
callback(error, vaultData);
});
}
@@ -176,3 +177,23 @@ export class DesktopAutotypeService {
return possibleCiphers;
}
}
/**
* @return an `AutotypeVaultData` object or an `Error` if the
* cipher or vault data within are undefined.
*/
export function getAutotypeVaultData(
cipherView: CipherView | undefined,
): Result<AutotypeVaultData> {
if (!cipherView) {
return [Error("No matching vault item."), null];
} else if (cipherView.login.username === undefined || cipherView.login.password === undefined) {
return [Error("Vault item is undefined."), null];
} else {
const vaultData: AutotypeVaultData = {
username: cipherView.login.username,
password: cipherView.login.password,
};
return [null, vaultData];
}
}

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100% 100%">
<text fill="%23333333" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
<text fill="%23333333" x="50%" y="50%" font-family="\'Inter\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
font-size="18" text-anchor="middle">
Loading...
</text>

View File

@@ -4193,5 +4193,29 @@
},
"cardNumberLabel": {
"message": "Card number"
},
"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"
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@bitwarden/desktop",
"version": "2025.10.2",
"version": "2025.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@bitwarden/desktop",
"version": "2025.10.2",
"version": "2025.11.0",
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/desktop-napi": "file:../desktop_native/napi"

View File

@@ -2,7 +2,7 @@
"name": "@bitwarden/desktop",
"productName": "Bitwarden",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.10.2",
"version": "2025.11.0",
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@@ -1,6 +1,6 @@
<form [bitSubmit]="submit" [formGroup]="approveSshRequestForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>{{ "sshkeyApprovalTitle" | i18n }}</div>
<div class="tw-font-medium" bitDialogTitle>{{ "sshkeyApprovalTitle" | i18n }}</div>
<div bitDialogContent>
<app-callout
type="warning"

View File

@@ -72,7 +72,7 @@
&.active {
.filter-button {
font-weight: bold;
font-weight: 500;
@include themify($themes) {
color: themed("primaryColor");
}
@@ -114,7 +114,7 @@
.filter-button {
@include themify($themes) {
color: themed("primaryColor");
font-weight: bold;
font-weight: 500;
}
max-width: 90%;
}

View File

@@ -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-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
$font-size-base: 14px;
$font-size-large: 18px;

View File

@@ -1,20 +1,31 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { DialogService } from "@bitwarden/components";
import { DesktopPremiumUpgradePromptService } from "./desktop-premium-upgrade-prompt.service";
describe("DesktopPremiumUpgradePromptService", () => {
let service: DesktopPremiumUpgradePromptService;
let messager: MockProxy<MessagingService>;
let configService: MockProxy<ConfigService>;
let dialogService: MockProxy<DialogService>;
beforeEach(async () => {
messager = mock<MessagingService>();
configService = mock<ConfigService>();
dialogService = mock<DialogService>();
await TestBed.configureTestingModule({
providers: [
DesktopPremiumUpgradePromptService,
{ provide: MessagingService, useValue: messager },
{ provide: ConfigService, useValue: configService },
{ provide: DialogService, useValue: dialogService },
],
}).compileComponents();
@@ -22,9 +33,38 @@ describe("DesktopPremiumUpgradePromptService", () => {
});
describe("promptForPremium", () => {
it("navigates to the premium update screen", async () => {
let openSpy: jest.SpyInstance;
beforeEach(() => {
openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation();
});
afterEach(() => {
openSpy.mockRestore();
});
it("opens the new premium upgrade dialog when feature flag is enabled", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
await service.promptForPremium();
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
expect(openSpy).toHaveBeenCalledWith(dialogService);
expect(messager.send).not.toHaveBeenCalled();
});
it("sends openPremium message when feature flag is disabled", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
await service.promptForPremium();
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
expect(messager.send).toHaveBeenCalledWith("openPremium");
expect(openSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,15 +1,29 @@
import { inject } from "@angular/core";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { DialogService } from "@bitwarden/components";
/**
* This class handles the premium upgrade process for the desktop.
*/
export class DesktopPremiumUpgradePromptService implements PremiumUpgradePromptService {
private messagingService = inject(MessagingService);
private configService = inject(ConfigService);
private dialogService = inject(DialogService);
async promptForPremium() {
this.messagingService.send("openPremium");
const showNewDialog = await this.configService.getFeatureFlag(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
if (showNewDialog) {
PremiumUpgradeDialogComponent.open(this.dialogService);
} else {
this.messagingService.send("openPremium");
}
}
}

View File

@@ -9,6 +9,7 @@ config.content = [
"../../libs/key-management-ui/src/**/*.{html,ts}",
"../../libs/angular/src/**/*.{html,ts}",
"../../libs/vault/src/**/*.{html,ts,mdx}",
"../../libs/pricing/src/**/*.{html,ts}",
];
module.exports = config;

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2025.10.1",
"version": "2025.11.0",
"scripts": {
"build:oss": "webpack",
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@@ -0,0 +1,70 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom, Observable, switchMap, tap } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { UserId } from "@bitwarden/user-core";
/**
* This guard is intended to prevent members of an organization from accessing
* routes based on compliance with organization
* policies. e.g Emergency access, which is a non-organization
* feature is restricted by the Auto Confirm policy.
*/
export function organizationPolicyGuard(
featureCallback: (
userId: UserId,
configService: ConfigService,
policyService: PolicyService,
) => Observable<boolean>,
): CanActivateFn {
return async () => {
const router = inject(Router);
const toastService = inject(ToastService);
const i18nService = inject(I18nService);
const accountService = inject(AccountService);
const policyService = inject(PolicyService);
const configService = inject(ConfigService);
const syncService = inject(SyncService);
const synced = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => syncService.lastSync$(userId)),
),
);
if (synced == null) {
await syncService.fullSync(false);
}
const compliant = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => featureCallback(userId, configService, policyService)),
tap((compliant) => {
if (typeof compliant !== "boolean") {
throw new Error("Feature callback must return a boolean.");
}
}),
),
);
if (!compliant) {
toastService.showToast({
variant: "error",
message: i18nService.t("noPageAccess"),
});
return router.createUrlTree(["/"]);
}
return compliant;
};
}

View File

@@ -63,7 +63,9 @@
bitFormButton
type="submit"
>
@if (autoConfirmEnabled$ | async) {
@let autoConfirmEnabled = autoConfirmEnabled$ | async;
@let managePoliciesOnly = managePolicies$ | async;
@if (autoConfirmEnabled || managePoliciesOnly) {
{{ "save" | i18n }}
} @else {
{{ "continue" | i18n }}

View File

@@ -22,6 +22,7 @@ import {
tap,
} from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -30,6 +31,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
import {
DIALOG_DATA,
DialogConfig,
@@ -83,6 +85,12 @@ export class AutoConfirmPolicyDialogComponent
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false),
);
protected managePolicies$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.organizationService.organizations$(userId)),
getById(this.data.organizationId),
map((organization) => (!organization?.isAdmin && organization?.canManagePolicies) ?? false),
);
private readonly submitPolicy: Signal<TemplateRef<unknown> | undefined> = viewChild("step0");
private readonly openExtension: Signal<TemplateRef<unknown> | undefined> = viewChild("step1");
@@ -105,6 +113,7 @@ export class AutoConfirmPolicyDialogComponent
toastService: ToastService,
configService: ConfigService,
keyService: KeyService,
private organizationService: OrganizationService,
private policyService: PolicyService,
private router: Router,
) {
@@ -146,22 +155,34 @@ export class AutoConfirmPolicyDialogComponent
tap((singleOrgPolicyEnabled) =>
this.policyComponent?.setSingleOrgEnabled(singleOrgPolicyEnabled),
),
map((singleOrgPolicyEnabled) => [
{
sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false),
footerContent: this.submitPolicy,
titleContent: this.submitPolicyTitle,
},
{
sideEffect: () => this.openBrowserExtension(),
footerContent: this.openExtension,
titleContent: this.openExtensionTitle,
},
]),
switchMap((singleOrgPolicyEnabled) => this.buildMultiStepSubmit(singleOrgPolicyEnabled)),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
private buildMultiStepSubmit(singleOrgPolicyEnabled: boolean): Observable<MultiStepSubmit[]> {
return this.managePolicies$.pipe(
map((managePoliciesOnly) => {
const submitSteps = [
{
sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false),
footerContent: this.submitPolicy,
titleContent: this.submitPolicyTitle,
},
];
if (!managePoliciesOnly) {
submitSteps.push({
sideEffect: () => this.openBrowserExtension(),
footerContent: this.openExtension,
titleContent: this.openExtensionTitle,
});
}
return submitSteps;
}),
);
}
private async handleSubmit(singleOrgEnabled: boolean) {
if (!singleOrgEnabled) {
await this.submitSingleOrg();

View File

@@ -27,7 +27,7 @@
{{ "autoConfirmSingleOrgRequired" | i18n }}
</span>
}
{{ "autoConfirmSingleOrgRequiredDescription" | i18n }}
{{ "autoConfirmSingleOrgRequiredDesc" | i18n }}
</li>
<li>

View File

@@ -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" },

View File

@@ -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);

View File

@@ -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, {

View File

@@ -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();
}

View File

@@ -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<boolean | UrlTree> => {
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) => {

View File

@@ -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,
) {

View File

@@ -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();

View File

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

View File

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

View File

@@ -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<UpgradeAccountComponent>;
const mockI18nService = mock<I18nService>();
const mockSubscriptionPricingService = mock<SubscriptionPricingService>();
const mockSubscriptionPricingService = mock<SubscriptionPricingServiceAbstraction>();
// 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, {

View File

@@ -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);

View File

@@ -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<DialogRef<UnifiedUpgradeDialogResult>>();
mockDialogRef.closed = of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium });
mockDialogService.open.mockReturnValue(mockDialogRef);
await component.upgrade();
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
});

View File

@@ -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`;

View File

@@ -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";

View File

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

View File

@@ -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),

View File

@@ -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) {

View File

@@ -675,7 +675,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
});
}
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
if (!this.acceptingSponsorship && !this.isInTrialFlow) {

View File

@@ -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);
});
});
});

View File

@@ -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<boolean>(
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<boolean | null> {
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<void> {
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<void> {
if (!userId) {
throw new Error("UserId is required. Cannot clear 'premiumInterest'.");
}
await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, null, userId);
}
}

View File

@@ -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",

View File

@@ -18,7 +18,7 @@
<div bitTypography="body2">
{{ "accessing" | i18n }}:
<a [routerLink]="[]" [bitMenuTriggerFor]="environmentOptions">
<b class="tw-text-primary-600 tw-font-semibold">{{ currentRegion?.domain }}</b>
<b class="tw-text-primary-600 tw-font-medium">{{ currentRegion?.domain }}</b>
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</a>
</div>

View File

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

View File

@@ -15,14 +15,12 @@
<h3 class="tw-mb-4 tw-text-xl tw-font-bold">{{ title }}</h3>
<p class="tw-mb-0">{{ description }}</p>
</bit-card-content>
<span
bitBadge
[variant]="requiresPremium ? 'success' : 'primary'"
class="tw-absolute tw-left-2 tw-top-2 tw-leading-none"
*ngIf="disabled"
>
<ng-container *ngIf="requiresPremium">{{ "premium" | i18n }}</ng-container>
<ng-container *ngIf="!requiresPremium">{{ "upgrade" | i18n }}</ng-container>
</span>
@if (requiresPremium) {
<app-premium-badge class="tw-absolute tw-left-2 tw-top-2"></app-premium-badge>
} @else if (requiresUpgrade) {
<span bitBadge variant="primary" class="tw-absolute tw-left-2 tw-top-2">
{{ "upgrade" | i18n }}
</span>
}
</bit-base-card>
</a>

View File

@@ -37,4 +37,8 @@ export class ReportCardComponent {
protected get requiresPremium() {
return this.variant == ReportVariant.RequiresPremium;
}
protected get requiresUpgrade() {
return this.variant == ReportVariant.RequiresUpgrade;
}
}

View File

@@ -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)],

View File

@@ -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)],

View File

@@ -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],
})

View File

@@ -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;
}
}

View File

@@ -12,7 +12,7 @@
<h1
bitTypography="h1"
noMargin
class="tw-m-0 tw-mr-2 tw-leading-10 tw-flex tw-gap-1"
class="tw-m-0 tw-mr-2 tw-leading-10 tw-flex tw-gap-1 tw-font-medium"
[title]="title || (routeData.titleId | i18n)"
>
<div class="tw-truncate">

View File

@@ -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"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
@@ -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"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>

View File

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

View File

@@ -20,10 +20,12 @@
*ngIf="showSubscription$ | async"
></bit-nav-item>
<bit-nav-item [text]="'domainRules' | i18n" route="settings/domain-rules"></bit-nav-item>
<bit-nav-item
[text]="'emergencyAccess' | i18n"
route="settings/emergency-access"
></bit-nav-item>
@if (showEmergencyAccess()) {
<bit-nav-item
[text]="'emergencyAccess' | i18n"
route="settings/emergency-access"
></bit-nav-item>
}
<billing-free-families-nav-item></billing-free-families-nav-item>
</bit-nav-group>
</app-side-nav>

Some files were not shown because too many files have changed in this diff Show More