1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-05 03:03:26 +00:00

Merge branch 'main' into add-risk-over-time-chart

This commit is contained in:
Maximilian Power
2025-11-03 23:40:07 +01:00
committed by GitHub
162 changed files with 4007 additions and 1211 deletions

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"
},
@@ -5772,6 +5766,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

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

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

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

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

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

View File

@@ -86,10 +86,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

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

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

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

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

@@ -55,6 +55,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
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";
@@ -94,6 +95,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,
@@ -408,7 +410,16 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: PremiumUpgradePromptService,
useClass: WebVaultPremiumUpgradePromptService,
deps: [DialogService, Router],
deps: [
DialogService,
ConfigService,
AccountService,
ApiService,
SyncService,
BillingAccountProfileStateService,
PlatformUtilsService,
Router,
],
}),
];

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>

View File

@@ -1,14 +1,20 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { Component, OnInit, Signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { RouterModule } from "@angular/router";
import { Observable, switchMap } from "rxjs";
import { combineLatest, map, Observable, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { IconModule } from "@bitwarden/components";
@@ -32,6 +38,7 @@ import { WebLayoutModule } from "./web-layout.module";
})
export class UserLayoutComponent implements OnInit {
protected readonly logo = PasswordManagerLogo;
protected readonly showEmergencyAccess: Signal<boolean>;
protected hasFamilySponsorshipAvailable$: Observable<boolean>;
protected showSponsoredFamilies$: Observable<boolean>;
protected showSubscription$: Observable<boolean>;
@@ -40,12 +47,33 @@ export class UserLayoutComponent implements OnInit {
private syncService: SyncService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private accountService: AccountService,
private policyService: PolicyService,
private configService: ConfigService,
) {
this.showSubscription$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.canViewSubscription$(account.id),
),
);
this.showEmergencyAccess = toSignal(
combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId),
),
),
]).pipe(
map(([enabled, policyAppliesToUser]) => {
if (!enabled || !policyAppliesToUser) {
return true;
}
return false;
}),
),
);
}
async ngOnInit() {

View File

@@ -47,11 +47,13 @@ import {
TwoFactorAuthGuard,
NewDeviceVerificationComponent,
} from "@bitwarden/auth/angular";
import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
import { LockComponent } from "@bitwarden/key-management-ui";
import { flagEnabled, Flags } from "../utils/flags";
import { organizationPolicyGuard } from "./admin-console/organizations/guards/org-policy.guard";
import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/manage/verify-recover-delete-org.component";
import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component";
import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component";
@@ -687,11 +689,13 @@ const routes: Routes = [
{
path: "",
component: EmergencyAccessComponent,
canActivate: [organizationPolicyGuard(canAccessEmergencyAccess)],
data: { titleId: "emergencyAccess" } satisfies RouteDataProperties,
},
{
path: ":id",
component: EmergencyAccessViewComponent,
canActivate: [organizationPolicyGuard(canAccessEmergencyAccess)],
data: { titleId: "emergencyAccess" } satisfies RouteDataProperties,
},
],

View File

@@ -6,11 +6,14 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogRef, DIALOG_DATA, DialogService, ToastService } from "@bitwarden/components";
@@ -73,6 +76,7 @@ describe("VaultItemDialogComponent", () => {
{ provide: LogService, useValue: {} },
{ provide: CipherService, useValue: {} },
{ provide: AccountService, useValue: { activeAccount$: { pipe: () => ({}) } } },
{ provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false) } },
{ provide: Router, useValue: {} },
{ provide: ActivatedRoute, useValue: {} },
{
@@ -84,6 +88,8 @@ describe("VaultItemDialogComponent", () => {
{ provide: ApiService, useValue: {} },
{ provide: EventCollectionService, useValue: {} },
{ provide: RoutedVaultFilterService, useValue: {} },
{ provide: SyncService, useValue: {} },
{ provide: PlatformUtilsService, useValue: {} },
],
}).compileComponents();

View File

@@ -65,6 +65,7 @@ import { SyncService } from "@bitwarden/common/platform/sync";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -326,6 +327,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
private organizationWarningsService: OrganizationWarningsService,
private policyService: PolicyService,
private unifiedUpgradePromptService: UnifiedUpgradePromptService,
private premiumUpgradePromptService: PremiumUpgradePromptService,
) {}
async ngOnInit() {
@@ -867,7 +869,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}
if (cipher.organizationId == null && !this.canAccessPremium) {
this.messagingService.send("premiumRequired");
await this.premiumUpgradePromptService.promptForPremium();
return;
} else if (cipher.organizationId != null) {
const org = await firstValueFrom(

View File

@@ -2,8 +2,19 @@ import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { lastValueFrom, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService } from "@bitwarden/components";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogStatus,
} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component";
@@ -13,13 +24,27 @@ describe("WebVaultPremiumUpgradePromptService", () => {
let service: WebVaultPremiumUpgradePromptService;
let dialogServiceMock: jest.Mocked<DialogService>;
let routerMock: jest.Mocked<Router>;
let dialogRefMock: jest.Mocked<DialogRef<VaultItemDialogResult>>;
let dialogRefMock: jest.Mocked<DialogRef>;
let configServiceMock: jest.Mocked<ConfigService>;
let accountServiceMock: jest.Mocked<AccountService>;
let apiServiceMock: jest.Mocked<ApiService>;
let syncServiceMock: jest.Mocked<SyncService>;
let billingAccountProfileServiceMock: jest.Mocked<BillingAccountProfileStateService>;
let platformUtilsServiceMock: jest.Mocked<PlatformUtilsService>;
beforeEach(() => {
dialogServiceMock = {
openSimpleDialog: jest.fn(),
} as unknown as jest.Mocked<DialogService>;
configServiceMock = {
getFeatureFlag: jest.fn().mockReturnValue(false),
} as unknown as jest.Mocked<ConfigService>;
accountServiceMock = {
activeAccount$: of({ id: "user-123" }),
} as unknown as jest.Mocked<AccountService>;
routerMock = {
navigate: jest.fn(),
} as unknown as jest.Mocked<Router>;
@@ -28,12 +53,34 @@ describe("WebVaultPremiumUpgradePromptService", () => {
close: jest.fn(),
} as unknown as jest.Mocked<DialogRef<VaultItemDialogResult>>;
apiServiceMock = {
refreshIdentityToken: jest.fn().mockReturnValue({}),
} as unknown as jest.Mocked<ApiService>;
syncServiceMock = {
fullSync: jest.fn(),
} as unknown as jest.Mocked<SyncService>;
billingAccountProfileServiceMock = {
hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)),
} as unknown as jest.Mocked<BillingAccountProfileStateService>;
platformUtilsServiceMock = {
isSelfHost: jest.fn().mockReturnValue(false),
} as unknown as jest.Mocked<PlatformUtilsService>;
TestBed.configureTestingModule({
providers: [
WebVaultPremiumUpgradePromptService,
{ provide: DialogService, useValue: dialogServiceMock },
{ provide: Router, useValue: routerMock },
{ provide: DialogRef, useValue: dialogRefMock },
{ provide: ConfigService, useValue: configServiceMock },
{ provide: AccountService, useValue: accountServiceMock },
{ provide: ApiService, useValue: apiServiceMock },
{ provide: SyncService, useValue: syncServiceMock },
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileServiceMock },
{ provide: PlatformUtilsService, useValue: platformUtilsServiceMock },
],
});
@@ -84,4 +131,144 @@ describe("WebVaultPremiumUpgradePromptService", () => {
expect(routerMock.navigate).not.toHaveBeenCalled();
expect(dialogRefMock.close).not.toHaveBeenCalled();
});
describe("premium status check", () => {
it("should not prompt if user already has premium (feature flag off)", async () => {
configServiceMock.getFeatureFlag.mockReturnValue(Promise.resolve(false));
billingAccountProfileServiceMock.hasPremiumFromAnySource$.mockReturnValue(of(true));
await service.promptForPremium();
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
expect(routerMock.navigate).not.toHaveBeenCalled();
});
it("should not prompt if user already has premium (feature flag on)", async () => {
configServiceMock.getFeatureFlag.mockImplementation((flag: FeatureFlag) => {
if (flag === FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog) {
return Promise.resolve(true);
}
return Promise.resolve(false);
});
billingAccountProfileServiceMock.hasPremiumFromAnySource$.mockReturnValue(of(true));
const unifiedDialogRefMock = {
closed: of({ status: UnifiedUpgradeDialogStatus.Closed }),
close: jest.fn(),
} as any;
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
await service.promptForPremium();
expect(UnifiedUpgradeDialogComponent.open).not.toHaveBeenCalled();
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
expect(routerMock.navigate).not.toHaveBeenCalled();
});
});
describe("new premium upgrade dialog with post-upgrade actions", () => {
beforeEach(() => {
configServiceMock.getFeatureFlag.mockImplementation((flag: FeatureFlag) => {
if (flag === FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog) {
return Promise.resolve(true);
}
return Promise.resolve(false);
});
});
describe("when self-hosted", () => {
beforeEach(() => {
platformUtilsServiceMock.isSelfHost.mockReturnValue(true);
});
it("should navigate to subscription page instead of opening dialog", async () => {
await service.promptForPremium();
expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]);
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
});
});
describe("when not self-hosted", () => {
beforeEach(() => {
platformUtilsServiceMock.isSelfHost.mockReturnValue(false);
});
it("should full sync when user upgrades to premium", async () => {
const unifiedDialogRefMock = {
closed: of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium }),
close: jest.fn(),
} as any;
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
await service.promptForPremium();
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
data: {
account: { id: "user-123" },
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
expect(syncServiceMock.fullSync).toHaveBeenCalledWith(true);
});
it("should full sync when user upgrades to families", async () => {
const unifiedDialogRefMock = {
closed: of({ status: UnifiedUpgradeDialogStatus.UpgradedToFamilies }),
close: jest.fn(),
} as any;
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
await service.promptForPremium();
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
data: {
account: { id: "user-123" },
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
expect(syncServiceMock.fullSync).toHaveBeenCalledWith(true);
});
it("should not refresh or sync when user closes dialog without upgrading", async () => {
const unifiedDialogRefMock = {
closed: of({ status: UnifiedUpgradeDialogStatus.Closed }),
close: jest.fn(),
} as any;
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
await service.promptForPremium();
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
data: {
account: { id: "user-123" },
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
expect(apiServiceMock.refreshIdentityToken).not.toHaveBeenCalled();
expect(syncServiceMock.fullSync).not.toHaveBeenCalled();
});
it("should not open new dialog if organizationId is provided", async () => {
const organizationId = "test-org-id" as OrganizationId;
dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true)));
const openSpy = jest.spyOn(UnifiedUpgradeDialogComponent, "open");
openSpy.mockClear();
await service.promptForPremium(organizationId);
expect(openSpy).not.toHaveBeenCalled();
expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "upgradeOrganization" },
content: { key: "upgradeOrganizationDesc" },
acceptButtonText: { key: "upgradeOrganization" },
type: "info",
});
});
});
});
});

View File

@@ -1,10 +1,21 @@
import { Injectable, Optional } from "@angular/core";
import { Router } from "@angular/router";
import { Subject } from "rxjs";
import { firstValueFrom, lastValueFrom, Subject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogStatus,
} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component";
@@ -15,14 +26,44 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
constructor(
private dialogService: DialogService,
private configService: ConfigService,
private accountService: AccountService,
private apiService: ApiService,
private syncService: SyncService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
@Optional() private dialog?: DialogRef<VaultItemDialogResult>,
) {}
private readonly subscriptionPageRoute = "settings/subscription/premium";
/**
* Prompts the user for a premium upgrade.
*/
async promptForPremium(organizationId?: OrganizationId) {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
return;
}
const hasPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
);
if (hasPremium) {
// Already has premium, don't prompt
return;
}
const showNewDialog = await this.configService.getFeatureFlag(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
// Per conversation in PM-23713, retain the existing upgrade org flow for now, will be addressed
// as a part of https://bitwarden.atlassian.net/browse/PM-25507
if (showNewDialog && !organizationId) {
await this.promptForPremiumVNext(account);
return;
}
let confirmed = false;
let route: string[] | null = null;
@@ -44,7 +85,7 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
type: "success",
});
if (confirmed) {
route = ["settings/subscription/premium"];
route = [this.subscriptionPageRoute];
}
}
@@ -57,4 +98,31 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
this.dialog.close(VaultItemDialogResult.PremiumUpgrade);
}
}
private async promptForPremiumVNext(account: Account) {
await (this.platformUtilsService.isSelfHost()
? this.redirectToSubscriptionPage()
: this.openUpgradeDialog(account));
}
private async redirectToSubscriptionPage() {
await this.router.navigate([this.subscriptionPageRoute]);
}
private async openUpgradeDialog(account: Account) {
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
data: {
account,
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (
result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
) {
await this.syncService.fullSync(true);
}
}
}

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="%23FBFBFB" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
<text fill="%23FBFBFB" x="50%" y="50%" font-family="\'Inter\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
font-size="18" text-anchor="middle">
Loading...
</text>

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

@@ -65,6 +65,9 @@
"reviewAtRiskPasswords": {
"message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords."
},
"reviewAtRiskLoginsPrompt": {
"message": "Review at-risk logins"
},
"dataLastUpdated": {
"message": "Data last updated: $DATE$",
"placeholders": {
@@ -166,6 +169,9 @@
}
}
},
"criticalApplicationsMarked": {
"message": "critical applications marked"
},
"countOfCriticalApplications": {
"message": "$COUNT$ critical applications",
"placeholders": {
@@ -274,6 +280,15 @@
"applicationsMarkedAsCriticalSuccess": {
"message": "Applications marked as critical"
},
"criticalApplicationsMarkedSuccess": {
"message": "$COUNT$ applications marked as critical",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"applicationsMarkedAsCriticalFail": {
"message": "Failed to mark applications as critical"
},
@@ -298,6 +313,12 @@
"membersWithAccessToAtRiskItemsForCriticalApps": {
"message": "Members with access to at-risk items for critical applications"
},
"membersWithAtRiskPasswords": {
"message": "Members with at-risk passwords"
},
"membersWillReceiveNotification": {
"message": "Members will receive a notification to resolve at-risk logins through the browser extension."
},
"membersAtRiskCount": {
"message": "$COUNT$ members at-risk",
"placeholders": {
@@ -391,8 +412,11 @@
"prioritizeCriticalApplications": {
"message": "Prioritize critical applications"
},
"atRiskItems": {
"message": "At-risk items"
"selectCriticalApplicationsDescription": {
"message": "Select which applications are most critical to your organization, then assign security tasks to members to resolve risks."
},
"clickIconToMarkAppAsCritical": {
"message": "Click the star icon to mark an app as critical"
},
"markAsCriticalPlaceholder": {
"message": "Mark as critical functionality will be implemented in a future update"
@@ -400,15 +424,6 @@
"applicationReviewSaved": {
"message": "Application review saved"
},
"applicationsMarkedAsCritical": {
"message": "$COUNT$ applications marked as critical",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"newApplicationsReviewed": {
"message": "New applications reviewed"
},
@@ -880,6 +895,9 @@
"favorites": {
"message": "Favorites"
},
"taskSummary": {
"message": "Task summary"
},
"types": {
"message": "Types"
},
@@ -5853,8 +5871,8 @@
"autoConfirmSingleOrgRequired": {
"message": "Single organization policy required. "
},
"autoConfirmSingleOrgRequiredDescription": {
"message": "Anyone part of more than one organization will have their access revoked until they leave the other organizations."
"autoConfirmSingleOrgRequiredDesc": {
"message": "All members must only belong to this organization to activate this automation."
},
"autoConfirmSingleOrgExemption": {
"message": "Single organization policy will extend to all roles. "
@@ -6586,11 +6604,11 @@
"updateWeakMasterPasswordWarning": {
"message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour."
},
"automaticAppLogin": {
"message": "Automatically log in users for allowed applications"
"automaticAppLoginWithSSO": {
"message": "Automatic login with SSO"
},
"automaticAppLoginDesc": {
"message": "Login forms will automatically be filled and submitted for apps launched from your configured identity provider."
"automaticAppLoginWithSSODesc": {
"message": "Extend SSO security and convenience to unmanaged apps. When users launch an app from your identity provider, their login details are automatically filled and submitted, creating a one-click, secure flow from the identity provider to the app."
},
"automaticAppLoginIdpHostLabel": {
"message": "Identity provider host"
@@ -7365,6 +7383,9 @@
"accessDenied": {
"message": "Access denied. You do not have permission to view this page."
},
"noPageAccess": {
"message": "You do not have access to this page"
},
"masterPassword": {
"message": "Master password"
},
@@ -9823,6 +9844,9 @@
"assignTasks": {
"message": "Assign tasks"
},
"assignTasksToMembers": {
"message": "Assign tasks to members for guided resolution"
},
"assignToCollections": {
"message": "Assign to collections"
},

View File

@@ -86,11 +86,11 @@
*/
@layer components {
.tw-h1 {
@apply tw-text-3xl tw-font-semibold;
@apply tw-text-3xl tw-font-medium;
}
.tw-btn {
@apply tw-font-semibold tw-py-1.5 tw-px-3 tw-rounded-full tw-transition tw-border-2 tw-border-solid tw-text-center tw-no-underline focus:tw-outline-none;
@apply tw-font-medium tw-py-1.5 tw-px-3 tw-rounded-full tw-transition tw-border-2 tw-border-solid tw-text-center tw-no-underline focus:tw-outline-none;
}
.tw-btn-secondary {
@@ -100,7 +100,7 @@
}
.tw-link {
@apply tw-font-semibold hover:tw-underline hover:tw-decoration-1;
@apply tw-font-medium hover:tw-underline hover:tw-decoration-1;
@apply tw-text-primary-600 hover:tw-text-primary-700 focus-visible:before:tw-ring-primary-600;
}

View File

@@ -30,7 +30,7 @@
color: rgb(var(--color-primary-600));
}
&.active {
font-weight: bold;
font-weight: 500;
}
}
}
@@ -59,7 +59,7 @@
> .filter-buttons {
.filter-button {
color: rgb(var(--color-primary-600));
font-weight: bold;
font-weight: 500;
}
.edit-button {

View File

@@ -56,6 +56,7 @@ import {
OrganizationReportSummary,
ReportStatus,
ReportState,
ApplicationHealthReportDetail,
} from "../../models/report-models";
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
@@ -98,18 +99,28 @@ export class RiskInsightsOrchestratorService {
enrichedReportData$ = this._enrichedReportDataSubject.asObservable();
// New applications that haven't been reviewed (reviewedDate === null)
newApplications$: Observable<string[]> = this.rawReportData$.pipe(
newApplications$: Observable<ApplicationHealthReportDetail[]> = this.rawReportData$.pipe(
map((reportState) => {
if (!reportState.data?.applicationData) {
return [];
}
return reportState.data.applicationData
.filter((app) => app.reviewedDate === null)
.map((app) => app.applicationName);
const reportApplications = reportState.data?.applicationData || [];
const newApplications =
reportState?.data?.reportData.filter((reportApp) =>
reportApplications.some(
(app) => app.applicationName == reportApp.applicationName && app.reviewedDate == null,
),
) || [];
return newApplications;
}),
distinctUntilChanged((prev, curr) => {
if (prev.length !== curr.length) {
return false;
}
return prev.every(
(app, i) =>
app.applicationName === curr[i].applicationName &&
app.atRiskPasswordCount === curr[i].atRiskPasswordCount,
);
}),
distinctUntilChanged(
(prev, curr) => prev.length === curr.length && prev.every((app, i) => app === curr[i]),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
@@ -332,9 +343,12 @@ export class RiskInsightsOrchestratorService {
}
// Create a set for quick lookup of the new critical apps
const newCriticalAppNamesSet = new Set(criticalApplications);
const newCriticalAppNamesSet = criticalApplications.map((ca) => ({
applicationName: ca,
isCritical: true,
}));
const existingApplicationData = report!.applicationData || [];
const updatedApplicationData = this._mergeApplicationData(
const updatedApplicationData = this._updateApplicationData(
existingApplicationData,
newCriticalAppNamesSet,
);
@@ -443,18 +457,18 @@ export class RiskInsightsOrchestratorService {
}
/**
* Saves review status for new applications and optionally marks selected ones as critical.
* This method:
* 1. Sets reviewedDate to current date for all applications where reviewedDate === null
* 2. Sets isCritical = true for applications in the selectedCriticalApps array
* Saves review status for new applications and optionally marks
* selected ones as critical
*
* @param selectedCriticalApps Array of application names to mark as critical (can be empty)
* @param reviewedApplications Array of application names to mark as reviewed
* @returns Observable of updated ReportState
*/
saveApplicationReviewStatus$(selectedCriticalApps: string[]): Observable<ReportState> {
this.logService.info("[RiskInsightsOrchestratorService] Saving application review status", {
criticalAppsCount: selectedCriticalApps.length,
});
saveApplicationReviewStatus$(
reviewedApplications: OrganizationReportApplication[],
): Observable<ReportState> {
this.logService.info(
`[RiskInsightsOrchestratorService] Saving application review status for ${reviewedApplications.length} applications`,
);
return this.rawReportData$.pipe(
take(1),
@@ -464,16 +478,43 @@ export class RiskInsightsOrchestratorService {
this._userId$.pipe(filter((userId) => !!userId)),
),
map(([reportState, organizationDetails, userId]) => {
const report = reportState?.data;
if (!report) {
throwError(() => Error("Tried save reviewed applications without a report"));
}
const existingApplicationData = reportState?.data?.applicationData || [];
const updatedApplicationData = this._updateReviewStatusAndCriticalFlags(
const updatedApplicationData = this._updateApplicationData(
existingApplicationData,
selectedCriticalApps,
reviewedApplications,
);
// Updated summary data after changing critical apps
const updatedSummaryData = this.reportService.getApplicationsSummary(
report!.reportData,
updatedApplicationData,
);
// Used for creating metrics with updated application data
const manualEnrichedApplications = report!.reportData.map(
(application): ApplicationHealthReportDetailEnriched => ({
...application,
isMarkedAsCritical: this.reportService.isCriticalApplication(
application,
updatedApplicationData,
),
}),
);
// For now, merge the report with the critical marking flag to make the enriched type
// We don't care about the individual ciphers in this instance
// After the report and enriched report types are consolidated, this mapping can be removed
// and the class will expose getCriticalApplications
const metrics = this._getReportMetrics(manualEnrichedApplications, updatedSummaryData);
const updatedState = {
...reportState,
data: {
...reportState.data,
summaryData: updatedSummaryData,
applicationData: updatedApplicationData,
},
} as ReportState;
@@ -484,9 +525,9 @@ export class RiskInsightsOrchestratorService {
criticalApps: updatedApplicationData.filter((app) => app.isCritical).length,
});
return { reportState, organizationDetails, updatedState, userId };
return { reportState, organizationDetails, updatedState, userId, metrics };
}),
switchMap(({ reportState, organizationDetails, updatedState, userId }) => {
switchMap(({ reportState, organizationDetails, updatedState, userId, metrics }) => {
return from(
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
{
@@ -506,10 +547,11 @@ export class RiskInsightsOrchestratorService {
organizationDetails,
updatedState,
encryptedData,
metrics,
})),
);
}),
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
switchMap(({ reportState, organizationDetails, updatedState, encryptedData, metrics }) => {
this.logService.debug(
`[RiskInsightsOrchestratorService] Persisting review status - report id: ${reportState?.data?.id}`,
);
@@ -521,26 +563,44 @@ export class RiskInsightsOrchestratorService {
return of({ ...reportState });
}
return this.reportApiService
.updateRiskInsightsApplicationData$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
applicationData: encryptedData.encryptedApplicationData.toSdk(),
},
// Update applications data with critical marking
const updateApplicationsCall = this.reportApiService.updateRiskInsightsApplicationData$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
applicationData: encryptedData.encryptedApplicationData.toSdk(),
},
)
.pipe(
map(() => updatedState),
catchError((error: unknown) => {
this.logService.error(
"[RiskInsightsOrchestratorService] Failed to save review status",
error,
);
return of({ ...reportState, error: "Failed to save application review status" });
}),
);
},
);
// Update summary after recomputing
const updateSummaryCall = this.reportApiService.updateRiskInsightsSummary$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
summaryData: encryptedData.encryptedSummaryData.toSdk(),
metrics: metrics.toRiskInsightsMetricsData(),
},
},
);
return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe(
map(() => updatedState),
tap((finalState) => {
this._flagForUpdatesSubject.next({
...finalState,
});
}),
catchError((error: unknown) => {
this.logService.error(
"[RiskInsightsOrchestratorService] Failed to save review status",
error,
);
return of({ ...reportState, error: "Failed to save application review status" });
}),
);
}),
);
}
@@ -752,67 +812,40 @@ export class RiskInsightsOrchestratorService {
// Updates the existing application data to include critical applications
// Does not remove critical applications not in the set
private _mergeApplicationData(
private _updateApplicationData(
existingApplications: OrganizationReportApplication[],
criticalApplications: Set<string>,
updatedApplications: (Partial<OrganizationReportApplication> & { applicationName: string })[],
): OrganizationReportApplication[] {
const setToMerge = new Set(criticalApplications);
const arrayToMerge = [...updatedApplications];
const updatedApps = existingApplications.map((app) => {
const foundCritical = setToMerge.has(app.applicationName);
// Check if there is an updated app
const foundUpdatedIndex = arrayToMerge.findIndex(
(ua) => ua.applicationName == app.applicationName,
);
if (foundCritical) {
setToMerge.delete(app.applicationName);
let foundApp: Partial<OrganizationReportApplication> | null = null;
// Remove the updated app from the list
if (foundUpdatedIndex >= 0) {
foundApp = arrayToMerge[foundUpdatedIndex];
arrayToMerge.splice(foundUpdatedIndex, 1);
}
return {
...app,
isCritical: foundCritical || app.isCritical,
applicationName: app.applicationName,
isCritical: foundApp?.isCritical || app.isCritical,
reviewedDate: foundApp?.reviewedDate || app.reviewedDate,
};
});
setToMerge.forEach((applicationName) => {
updatedApps.push({
applicationName,
isCritical: true,
const newElements: OrganizationReportApplication[] = arrayToMerge.map(
(newApp): OrganizationReportApplication => ({
applicationName: newApp.applicationName,
isCritical: newApp.isCritical ?? false,
reviewedDate: null,
});
});
}),
);
return updatedApps;
}
/**
* Updates review status and critical flags for applications.
* Sets reviewedDate for all apps with null reviewedDate.
* Sets isCritical flag for apps in the criticalApplications array.
*
* @param existingApplications Current application data
* @param criticalApplications Array of application names to mark as critical
* @returns Updated application data with review dates and critical flags
*/
private _updateReviewStatusAndCriticalFlags(
existingApplications: OrganizationReportApplication[],
criticalApplications: string[],
): OrganizationReportApplication[] {
const criticalSet = new Set(criticalApplications);
const currentDate = new Date();
return existingApplications.map((app) => {
const shouldMarkCritical = criticalSet.has(app.applicationName);
const needsReviewDate = app.reviewedDate === null;
// Only create new object if changes are needed
if (needsReviewDate || shouldMarkCritical) {
return {
...app,
reviewedDate: needsReviewDate ? currentDate : app.reviewedDate,
isCritical: shouldMarkCritical || app.isCritical,
};
}
return app;
});
return updatedApps.concat(newElements);
}
// Toggles the isCritical flag on applications via criticalApplicationName

View File

@@ -10,6 +10,8 @@ import {
DrawerType,
RiskInsightsEnrichedData,
ReportStatus,
ApplicationHealthReportDetail,
OrganizationReportApplication,
} from "../../models";
import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service";
@@ -38,7 +40,7 @@ export class RiskInsightsDataService {
readonly hasCiphers$: Observable<boolean | null> = of(null);
// New applications that need review (reviewedDate === null)
readonly newApplications$: Observable<string[]> = of([]);
readonly newApplications$: Observable<ApplicationHealthReportDetail[]> = of([]);
// ------------------------- Drawer Variables ---------------------
// Drawer variables unified into a single BehaviorSubject
@@ -257,7 +259,7 @@ export class RiskInsightsDataService {
return this.orchestrator.removeCriticalApplication$(hostname);
}
saveApplicationReviewStatus(selectedCriticalApps: string[]) {
saveApplicationReviewStatus(selectedCriticalApps: OrganizationReportApplication[]) {
return this.orchestrator.saveApplicationReviewStatus$(selectedCriticalApps);
}
}

View File

@@ -11,8 +11,8 @@ import {
import { SharedModule } from "@bitwarden/web-vault/app/shared";
export class AutomaticAppLoginPolicy extends BasePolicyEditDefinition {
name = "automaticAppLogin";
description = "automaticAppLoginDesc";
name = "automaticAppLoginWithSSO";
description = "automaticAppLoginWithSSODesc";
type = PolicyType.AutomaticAppLogIn;
component = AutomaticAppLoginPolicyComponent;
}

View File

@@ -30,12 +30,14 @@ import { LogService } from "@bitwarden/logging";
import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service";
import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module";
import { NewApplicationsDialogComponent } from "./activity/application-review-dialog/new-applications-dialog.component";
import { RiskInsightsComponent } from "./risk-insights.component";
import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service";
@NgModule({
imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule],
imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule, NewApplicationsDialogComponent],
providers: [
safeProvider(DefaultAdminTaskService),
safeProvider({
provide: MemberCipherDetailsApiService,
useClass: MemberCipherDetailsApiService,

View File

@@ -1,10 +1,11 @@
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, lastValueFrom } from "rxjs";
import {
AllActivitiesService,
ApplicationHealthReportDetail,
ReportStatus,
RiskInsightsDataService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
@@ -13,6 +14,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { getById } from "@bitwarden/common/platform/misc";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
@@ -21,7 +23,7 @@ import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.co
import { ActivityCardComponent } from "./activity-card.component";
import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component";
import { NewApplicationsDialogComponent } from "./new-applications-dialog.component";
import { NewApplicationsDialogComponent } from "./application-review-dialog/new-applications-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -42,7 +44,7 @@ export class AllActivityComponent implements OnInit {
totalCriticalAppsCount = 0;
totalCriticalAppsAtRiskCount = 0;
newApplicationsCount = 0;
newApplications: string[] = [];
newApplications: ApplicationHealthReportDetail[] = [];
passwordChangeMetricHasProgressBar = false;
allAppsHaveReviewDate = false;
isAllCaughtUp = false;
@@ -129,27 +131,38 @@ export class AllActivityComponent implements OnInit {
* Handles the review new applications button click.
* Opens a dialog showing the list of new applications that can be marked as critical.
*/
onReviewNewApplications = async () => {
async onReviewNewApplications() {
const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId");
if (!organizationId) {
return;
}
// Pass organizationId via dialog data instead of having the dialog retrieve it from route.
// This ensures organizationId is immediately available when dialog opens, preventing
// timing issues where the dialog's checkForTasksToAssign() method runs before
// organizationId is populated via async route subscription.
const dialogRef = NewApplicationsDialogComponent.open(this.dialogService, {
newApplications: this.newApplications,
organizationId: organizationId as OrganizationId,
});
await firstValueFrom(dialogRef.closed);
};
await lastValueFrom(dialogRef.closed);
}
/**
* Handles the "View at-risk members" link click.
* Opens the at-risk members drawer for critical applications only.
*/
onViewAtRiskMembers = async () => {
async onViewAtRiskMembers() {
await this.dataService.setDrawerForCriticalAtRiskMembers("activityTabAtRiskMembers");
};
}
/**
* Handles the "View at-risk applications" link click.
* Opens the at-risk applications drawer for critical applications only.
*/
onViewAtRiskApplications = async () => {
async onViewAtRiskApplications() {
await this.dataService.setDrawerForCriticalAtRiskApps("activityTabAtRiskApplications");
};
}
}

View File

@@ -0,0 +1,78 @@
<div class="tw-flex tw-flex-col tw-gap-6">
<!-- Two-column layout: Left panel (stats) and Right panel (browser extension mockup) -->
<div class="tw-flex tw-flex-col md:tw-flex-row tw-gap-6">
<!-- Left Panel -->
<div class="tw-flex tw-flex-col tw-gap-4 tw-flex-1">
<!-- Task Summary Info Card -->
<bit-callout type="info" [title]="'taskSummary' | i18n" class="tw-mb-6">
<strong>{{ atRiskCriticalMembersCount() }}</strong>
{{ "membersWithAtRiskPasswords" | i18n }}
for
<strong>{{ criticalApplicationsCount() }}</strong>
{{ "criticalApplications" | i18n }}
</bit-callout>
<!-- Stat Box: Members with At-Risk Passwords -->
<div class="tw-flex tw-items-start tw-gap-3">
<bit-icon-tile
icon="bwi-users"
variant="primary"
size="large"
shape="circle"
aria-hidden="true"
></bit-icon-tile>
<div class="tw-flex tw-flex-col">
<span bitTypography="h2" class="tw-font-bold tw-mb-1">
{{ atRiskCriticalMembersCount() }}
</span>
<span bitTypography="body2" class="tw-text-muted">
{{ "membersWithAtRiskPasswords" | i18n }}
</span>
</div>
</div>
<!-- Stat Box: Critical Applications At-Risk -->
<div class="tw-flex tw-items-start tw-gap-3">
<bit-icon-tile
icon="bwi-desktop"
variant="warning"
size="large"
shape="circle"
aria-hidden="true"
></bit-icon-tile>
<div class="tw-flex tw-flex-col">
<div class="tw-flex tw-items-baseline tw-gap-2 tw-mb-1">
<span bitTypography="h2" class="tw-font-bold tw-text-main">
{{ criticalApplicationsCount() }}
</span>
<span bitTypography="body1" class="tw-text-muted">
of {{ totalApplicationsCount() }} total
</span>
</div>
<span bitTypography="body2" class="tw-text-muted">
{{ "criticalApplications" | i18n }} at-risk
</span>
</div>
</div>
</div>
<!-- Right Panel: Browser Extension Video -->
<div class="tw-flex tw-flex-col tw-gap-4 tw-flex-1">
<video
class="tw-w-full tw-rounded-lg"
autoplay
loop
muted
playsinline
src="/videos/access-intelligence-assign-tasks.mp4"
appDarkImgSrc="/videos/access-intelligence-assign-tasks-dark.mp4"
aria-hidden="true"
></video>
<!-- Description Text -->
<div bitTypography="helper" class="tw-text-muted">
{{ "membersWillReceiveNotification" | i18n }}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,45 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import {
ButtonModule,
CalloutComponent,
IconTileComponent,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { DarkImageSourceDirective } from "@bitwarden/vault";
import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service";
import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service";
/**
* Embedded component for displaying task assignment UI.
* Not a dialog - intended to be embedded within a parent dialog.
*
* Important: This component provides its own instances of AccessIntelligenceSecurityTasksService
* and DefaultAdminTaskService. These services are scoped to this component to ensure proper
* dependency injection when the component is dynamically rendered within the structure.
* Without these providers, Angular would throw NullInjectorError when trying to inject
* DefaultAdminTaskService, which is required by AccessIntelligenceSecurityTasksService.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "dirt-assign-tasks-view",
templateUrl: "./assign-tasks-view.component.html",
imports: [
CommonModule,
ButtonModule,
TypographyModule,
I18nPipe,
IconTileComponent,
DarkImageSourceDirective,
CalloutComponent,
],
providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService],
})
export class AssignTasksViewComponent {
readonly criticalApplicationsCount = input.required<number>();
readonly totalApplicationsCount = input.required<number>();
readonly atRiskCriticalMembersCount = input.required<number>();
}

View File

@@ -0,0 +1,97 @@
<bit-dialog [dialogSize]="'large'">
<span bitDialogTitle>
{{
currentView() === DialogView.SelectApplications
? ("prioritizeCriticalApplications" | i18n)
: ("assignTasksToMembers" | i18n)
}}
</span>
<div bitDialogContent>
@if (currentView() === DialogView.SelectApplications) {
<div>
<p bitTypography="body1" class="tw-mb-5">
{{ "selectCriticalApplicationsDescription" | i18n }}
</p>
<div class="tw-flex tw-items-center tw-gap-2.5 tw-mb-5">
<i class="bwi bwi-star-f tw-text-xl" aria-hidden="true"></i>
<p bitTypography="helper" class="tw-text-muted tw-mb-0">
{{ "clickIconToMarkAppAsCritical" | i18n }}
</p>
</div>
<dirt-review-applications-view
[applications]="getApplications()"
[selectedApplications]="selectedApplications()"
(onToggleSelection)="toggleSelection($event)"
(onToggleAll)="toggleAll()"
></dirt-review-applications-view>
</div>
}
@if (currentView() === DialogView.AssignTasks) {
<dirt-assign-tasks-view
[criticalApplicationsCount]="selectedApplications().size"
[totalApplicationsCount]="this.dialogParams.newApplications.length"
[atRiskCriticalMembersCount]="atRiskCriticalMembersCount()"
>
</dirt-assign-tasks-view>
}
</div>
@if (currentView() === DialogView.SelectApplications) {
<ng-container bitDialogFooter>
<button
type="button"
bitButton
size="small"
buttonType="primary"
(click)="handleMarkAsCritical()"
[disabled]="markingAsCritical()"
[loading]="markingAsCritical()"
[attr.aria-label]="'markAsCritical' | i18n"
>
{{ "markAsCritical" | i18n }}
</button>
<button
type="button"
bitButton
size="small"
buttonType="secondary"
[bitDialogClose]="false"
[attr.aria-label]="'cancel' | i18n"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
}
@if (currentView() == DialogView.AssignTasks) {
<!-- Footer: Action Buttons -->
<ng-container bitDialogFooter>
<button
type="button"
bitButton
size="small"
buttonType="primary"
(click)="handleAssignTasks()"
[disabled]="saving()"
[loading]="saving()"
[attr.aria-label]="'assignTasks' | i18n"
>
{{ "assignTasks" | i18n }}
</button>
<button
type="button"
bitButton
size="small"
buttonType="secondary"
(click)="onBack()"
[disabled]="saving()"
[attr.aria-label]="'back' | i18n"
>
{{ "back" | i18n }}
</button>
</ng-container>
}
</bit-dialog>

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