1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-16 08:34:39 +00:00

Merge branch 'main' into dirt/pm-27706/columns-for-new-apps-dialog

This commit is contained in:
Leslie Tilton
2025-11-03 09:56:39 -06:00
70 changed files with 2171 additions and 995 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

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

@@ -232,7 +232,7 @@
<input formControlName="enableAutoTotpCopy" bitCheckbox id="totp" type="checkbox" />
<bit-label for="totp">{{ "enableAutoTotpCopy" | i18n }}</bit-label>
</bit-form-control>
<bit-form-field>
<bit-form-field [disableMargin]="isDefaultUriMatchDisabledByPolicy">
<bit-label for="clearClipboard">{{ "clearClipboard" | i18n }}</bit-label>
<bit-select
formControlName="clearClipboard"
@@ -250,7 +250,7 @@
{{ "clearClipboardDesc" | i18n }}
</bit-hint>
</bit-form-field>
<bit-form-field disableMargin>
<bit-form-field disableMargin *ngIf="!isDefaultUriMatchDisabledByPolicy">
<bit-label for="defaultUriMatch">{{ "defaultUriMatchDetection" | i18n }}</bit-label>
<bit-select
formControlName="defaultUriMatch"
@@ -265,9 +265,6 @@
[disabled]="option.disabled"
></bit-option>
</bit-select>
<bit-hint *ngIf="isDefaultUriMatchDisabledByPolicy">
{{ "settingDisabledByPolicy" | i18n }}
</bit-hint>
<bit-hint *ngIf="getMatchHints() as hints">
{{ hints[0] | i18n }}
<ng-container *ngIf="hints.length > 1">

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

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

View File

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

View File

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

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

View File

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

View File

@@ -18,4 +18,7 @@ export abstract class DesktopBiometricsService extends BiometricsService {
/* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */
abstract enableWindowsV2Biometrics(): Promise<void>;
abstract isWindowsV2BiometricsEnabled(): Promise<boolean>;
/* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */
abstract enableLinuxV2Biometrics(): Promise<void>;
abstract isLinuxV2BiometricsEnabled(): Promise<boolean>;
}

View File

@@ -62,6 +62,10 @@ export class MainBiometricsIPCListener {
return await this.biometricService.enableWindowsV2Biometrics();
case BiometricAction.IsWindowsV2Enabled:
return await this.biometricService.isWindowsV2BiometricsEnabled();
case BiometricAction.EnableLinuxV2:
return await this.biometricService.enableLinuxV2Biometrics();
case BiometricAction.IsLinuxV2Enabled:
return await this.biometricService.isLinuxV2BiometricsEnabled();
default:
return;
}

View File

@@ -10,13 +10,14 @@ import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-manageme
import { WindowMain } from "../../main/window.main";
import { DesktopBiometricsService } from "./desktop.biometrics.service";
import { WindowsBiometricsSystem } from "./native-v2";
import { LinuxBiometricsSystem, WindowsBiometricsSystem } from "./native-v2";
import { OsBiometricService } from "./os-biometrics.service";
export class MainBiometricsService extends DesktopBiometricsService {
private osBiometricsService: OsBiometricService;
private shouldAutoPrompt = true;
private windowsV2BiometricsEnabled = false;
private linuxV2BiometricsEnabled = false;
constructor(
private i18nService: I18nService,
@@ -170,4 +171,16 @@ export class MainBiometricsService extends DesktopBiometricsService {
async isWindowsV2BiometricsEnabled(): Promise<boolean> {
return this.windowsV2BiometricsEnabled;
}
async enableLinuxV2Biometrics(): Promise<void> {
if (this.platform === "linux" && !this.linuxV2BiometricsEnabled) {
this.logService.info("[BiometricsMain] Loading native biometrics module v2 for linux");
this.osBiometricsService = new LinuxBiometricsSystem();
this.linuxV2BiometricsEnabled = true;
}
}
async isLinuxV2BiometricsEnabled(): Promise<boolean> {
return this.linuxV2BiometricsEnabled;
}
}

View File

@@ -1 +1,2 @@
export { default as WindowsBiometricsSystem } from "./os-biometrics-windows.service";
export { default as LinuxBiometricsSystem } from "./os-biometrics-linux.service";

View File

@@ -0,0 +1,96 @@
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics_v2, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus } from "@bitwarden/key-management";
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
jest.mock("@bitwarden/desktop-napi", () => ({
biometrics_v2: {
initBiometricSystem: jest.fn(() => "mockSystem"),
provideKey: jest.fn(),
unenroll: jest.fn(),
unlock: jest.fn(),
authenticate: jest.fn(),
authenticateAvailable: jest.fn(),
unlockAvailable: jest.fn(),
},
passwords: {
isAvailable: jest.fn(),
},
}));
const mockKey = new Uint8Array(64);
jest.mock("../../../utils", () => ({
isFlatpak: jest.fn(() => false),
isLinux: jest.fn(() => true),
isSnapStore: jest.fn(() => false),
}));
describe("OsBiometricsServiceLinux", () => {
const userId = "user-id" as UserId;
const key = { toEncoded: () => ({ buffer: Buffer.from(mockKey) }) } as SymmetricCryptoKey;
let service: OsBiometricsServiceLinux;
beforeEach(() => {
service = new OsBiometricsServiceLinux();
jest.clearAllMocks();
});
it("should set biometric key", async () => {
await service.setBiometricKey(userId, key);
expect(biometrics_v2.provideKey).toHaveBeenCalled();
});
it("should delete biometric key", async () => {
await service.deleteBiometricKey(userId);
expect(biometrics_v2.unenroll).toHaveBeenCalled();
});
it("should get biometric key", async () => {
(biometrics_v2.unlock as jest.Mock).mockResolvedValue(mockKey);
const result = await service.getBiometricKey(userId);
expect(result).toBeInstanceOf(SymmetricCryptoKey);
});
it("should return null if no biometric key", async () => {
(biometrics_v2.unlock as jest.Mock).mockResolvedValue(null);
const result = await service.getBiometricKey(userId);
expect(result).toBeNull();
});
it("should authenticate biometric", async () => {
(biometrics_v2.authenticate as jest.Mock).mockResolvedValue(true);
const result = await service.authenticateBiometric();
expect(result).toBe(true);
});
it("should check if biometrics is supported", async () => {
(passwords.isAvailable as jest.Mock).mockResolvedValue(true);
const result = await service.supportsBiometrics();
expect(result).toBe(true);
});
it("should check if setup is needed", async () => {
(biometrics_v2.authenticateAvailable as jest.Mock).mockResolvedValue(false);
const result = await service.needsSetup();
expect(result).toBe(true);
});
it("should check if can auto setup", async () => {
const result = await service.canAutoSetup();
expect(result).toBe(true);
});
it("should get biometrics first unlock status for user", async () => {
(biometrics_v2.unlockAvailable as jest.Mock).mockResolvedValue(true);
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.Available);
});
it("should return false for hasPersistentKey", async () => {
const result = await service.hasPersistentKey(userId);
expect(result).toBe(false);
});
});

View File

@@ -0,0 +1,118 @@
import { spawn } from "child_process";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics_v2, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus } from "@bitwarden/key-management";
import { isSnapStore, isFlatpak, isLinux } from "../../../utils";
import { OsBiometricService } from "../os-biometrics.service";
const polkitPolicy = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>
<action id="com.bitwarden.Bitwarden.unlock">
<description>Unlock Bitwarden</description>
<message>Authenticate to unlock Bitwarden</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_self</allow_active>
</defaults>
</action>
</policyconfig>`;
const policyFileName = "com.bitwarden.Bitwarden.policy";
const policyPath = "/usr/share/polkit-1/actions/";
export default class OsBiometricsServiceLinux implements OsBiometricService {
private biometricsSystem: biometrics_v2.BiometricLockSystem;
constructor() {
this.biometricsSystem = biometrics_v2.initBiometricSystem();
}
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
await biometrics_v2.provideKey(
this.biometricsSystem,
userId,
Buffer.from(key.toEncoded().buffer),
);
}
async deleteBiometricKey(userId: UserId): Promise<void> {
await biometrics_v2.unenroll(this.biometricsSystem, userId);
}
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
const result = await biometrics_v2.unlock(this.biometricsSystem, userId, Buffer.from(""));
return result ? new SymmetricCryptoKey(Uint8Array.from(result)) : null;
}
async authenticateBiometric(): Promise<boolean> {
return await biometrics_v2.authenticate(
this.biometricsSystem,
Buffer.from(""),
"Authenticate to unlock",
);
}
async supportsBiometrics(): Promise<boolean> {
// We assume all linux distros have some polkit implementation
// that either has bitwarden set up or not, which is reflected in osBiomtricsNeedsSetup.
// Snap does not have access at the moment to polkit
// This could be dynamically detected on dbus in the future.
// We should check if a libsecret implementation is available on the system
// because otherwise we cannot offlod the protected userkey to secure storage.
return await passwords.isAvailable();
}
async needsSetup(): Promise<boolean> {
if (isSnapStore()) {
return false;
}
// check whether the polkit policy is loaded via dbus call to polkit
return !(await biometrics_v2.authenticateAvailable(this.biometricsSystem));
}
async canAutoSetup(): Promise<boolean> {
// We cannot auto setup on snap or flatpak since the filesystem is sandboxed.
// The user needs to manually set up the polkit policy outside of the sandbox
// since we allow access to polkit via dbus for the sandboxed clients, the authentication works from
// the sandbox, once the policy is set up outside of the sandbox.
return isLinux() && !isSnapStore() && !isFlatpak();
}
async runSetup(): Promise<void> {
const process = spawn("pkexec", [
"bash",
"-c",
`echo '${polkitPolicy}' > ${policyPath + policyFileName} && chown root:root ${policyPath + policyFileName} && chcon system_u:object_r:usr_t:s0 ${policyPath + policyFileName}`,
]);
await new Promise((resolve, reject) => {
process.on("close", (code) => {
if (code !== 0) {
reject("Failed to set up polkit policy");
} else {
resolve(null);
}
});
});
}
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
return (await biometrics_v2.unlockAvailable(this.biometricsSystem, userId))
? BiometricsStatus.Available
: BiometricsStatus.UnlockNeeded;
}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {}
async hasPersistentKey(userId: UserId): Promise<boolean> {
return false;
}
}

View File

@@ -84,4 +84,12 @@ export class RendererBiometricsService extends DesktopBiometricsService {
async isWindowsV2BiometricsEnabled(): Promise<boolean> {
return await ipc.keyManagement.biometric.isWindowsV2BiometricsEnabled();
}
async enableLinuxV2Biometrics(): Promise<void> {
return await ipc.keyManagement.biometric.enableLinuxV2Biometrics();
}
async isLinuxV2BiometricsEnabled(): Promise<boolean> {
return await ipc.keyManagement.biometric.isLinuxV2BiometricsEnabled();
}
}

View File

@@ -69,6 +69,14 @@ const biometric = {
ipcRenderer.invoke("biometric", {
action: BiometricAction.IsWindowsV2Enabled,
} satisfies BiometricMessage),
enableLinuxV2Biometrics: (): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.EnableLinuxV2,
} satisfies BiometricMessage),
isLinuxV2BiometricsEnabled: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.IsLinuxV2Enabled,
} satisfies BiometricMessage),
};
export default {

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

@@ -125,6 +125,11 @@ export class BiometricMessageHandlerService {
if (windowsV2Enabled) {
await this.biometricsService.enableWindowsV2Biometrics();
}
const linuxV2Enabled = await this.configService.getFeatureFlag(FeatureFlag.LinuxBiometricsV2);
if (linuxV2Enabled) {
await this.biometricsService.enableLinuxV2Biometrics();
}
}
async handleMessage(msg: LegacyMessageWrapper) {

View File

@@ -19,6 +19,9 @@ export enum BiometricAction {
EnableWindowsV2 = "enableWindowsV2",
IsWindowsV2Enabled = "isWindowsV2Enabled",
EnableLinuxV2 = "enableLinuxV2",
IsLinuxV2Enabled = "isLinuxV2Enabled",
}
export type BiometricMessage =

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

@@ -10,6 +10,7 @@ export { RestrictedItemTypesPolicy } from "./restricted-item-types.component";
export { SendOptionsPolicy } from "./send-options.component";
export { SingleOrgPolicy } from "./single-org.component";
export { TwoFactorAuthenticationPolicy } from "./two-factor-authentication.component";
export { UriMatchDefaultPolicy } from "./uri-match-default.component";
export {
vNextOrganizationDataOwnershipPolicy,
vNextOrganizationDataOwnershipPolicyComponent,

View File

@@ -0,0 +1,22 @@
<bit-callout title="{{ 'prerequisite' | i18n }}">
{{ "requireSsoPolicyReq" | i18n }}
</bit-callout>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<div [formGroup]="data">
<bit-form-field class="tw-flex-auto">
<bit-label>{{ "uriMatchDetectionOptionsLabel" | i18n }}</bit-label>
<bit-select formControlName="uriMatchDetection" id="uriMatchDetection">
<bit-option
*ngFor="let o of uriMatchOptions"
[label]="o.label"
[value]="o.value"
[disabled]="o.disabled"
></bit-option>
</bit-select>
</bit-form-field>
</div>

View File

@@ -0,0 +1,72 @@
import { Component, ChangeDetectionStrategy } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import {
UriMatchStrategy,
UriMatchStrategySetting,
} from "@bitwarden/common/models/domain/domain-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SharedModule } from "../../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
export class UriMatchDefaultPolicy extends BasePolicyEditDefinition {
name = "uriMatchDetectionPolicy";
description = "uriMatchDetectionPolicyDesc";
type = PolicyType.UriMatchDefaults;
component = UriMatchDefaultPolicyComponent;
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "uri-match-default.component.html",
imports: [SharedModule],
})
export class UriMatchDefaultPolicyComponent extends BasePolicyEditComponent {
uriMatchOptions: { label: string; value: UriMatchStrategySetting | null; disabled?: boolean }[];
constructor(
private formBuilder: FormBuilder,
private i18nService: I18nService,
) {
super();
this.data = this.formBuilder.group({
uriMatchDetection: new FormControl<UriMatchStrategySetting>(UriMatchStrategy.Domain, {
validators: [Validators.required],
nonNullable: true,
}),
});
this.uriMatchOptions = [
{ label: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain },
{ label: i18nService.t("host"), value: UriMatchStrategy.Host },
{ label: i18nService.t("exact"), value: UriMatchStrategy.Exact },
{ label: i18nService.t("never"), value: UriMatchStrategy.Never },
];
}
protected loadData() {
const uriMatchDetection = this.policyResponse?.data?.uriMatchDetection;
this.data?.patchValue({
uriMatchDetection: uriMatchDetection,
});
}
protected buildRequestData() {
return {
uriMatchDetection: this.data?.value?.uriMatchDetection,
};
}
async buildRequest(): Promise<PolicyRequest> {
const request = await super.buildRequest();
if (request.data?.uriMatchDetection == null) {
throw new Error(this.i18nService.t("invalidUriMatchDefaultPolicySetting"));
}
return request;
}
}

View File

@@ -13,6 +13,7 @@ import {
SendOptionsPolicy,
SingleOrgPolicy,
TwoFactorAuthenticationPolicy,
UriMatchDefaultPolicy,
vNextOrganizationDataOwnershipPolicy,
} from "./policy-edit-definitions";
@@ -34,5 +35,6 @@ export const ossPolicyEditRegister: BasePolicyEditDefinition[] = [
new SendOptionsPolicy(),
new RestrictedItemTypesPolicy(),
new DesktopAutotypeDefaultSettingPolicy(),
new UriMatchDefaultPolicy(),
new AutoConfirmPolicy(),
];

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

@@ -241,6 +241,15 @@
"applicationsMarkedAsCriticalSuccess": {
"message": "Applications marked as critical"
},
"criticalApplicationsMarkedSuccess": {
"message": "$COUNT$ applications marked as critical",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"applicationsMarkedAsCriticalFail": {
"message": "Failed to mark applications as critical"
},
@@ -266,7 +275,7 @@
"message": "Members with access to at-risk items for critical applications"
},
"membersWithAtRiskPasswords": {
"message": "members with at-risk passwords"
"message": "Members with at-risk passwords"
},
"membersWillReceiveNotification": {
"message": "Members will receive a notification to resolve at-risk logins through the browser extension."
@@ -367,12 +376,12 @@
"selectCriticalApplicationsDescription": {
"message": "Select which applications are most critical to your organization, then assign security tasks to members to resolve risks."
},
"clickIconToMarkCritical": {
"message": "Click the icon to mark an app as critical"
},
"atRiskItems": {
"message": "At-risk items"
},
"clickIconToMarkAppAsCritical": {
"message": "Click the star icon to mark an app as critical"
},
"totalItems": {
"message": "Total items"
},
@@ -385,15 +394,6 @@
"applicationReviewSaved": {
"message": "Application review saved"
},
"applicationsMarkedAsCritical": {
"message": "$COUNT$ applications marked as critical",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"newApplicationsReviewed": {
"message": "New applications reviewed"
},
@@ -5841,8 +5841,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. "
@@ -5908,6 +5908,19 @@
"message": "Always show members email address with recipients when creating or editing a Send.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"uriMatchDetectionPolicy": {
"message": "Default URI match detection"
},
"uriMatchDetectionPolicyDesc": {
"message": "Determine when logins are suggested for autofill. Admins and owners are exempt from this policy."
},
"uriMatchDetectionOptionsLabel": {
"message": "Default URI match detection"
},
"invalidUriMatchDefaultPolicySetting": {
"message": "Please select a valid URI match detection option.",
"description": "Error message displayed when a user attempts to save URI match detection policy settings with an invalid selection."
},
"modifiedPolicyId": {
"message": "Modified policy $ID$.",
"placeholders": {
@@ -7340,6 +7353,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"
},
@@ -9686,7 +9702,7 @@
"message": "Common formats",
"description": "Label indicating the most common import formats"
},
"uriMatchDefaultStrategyHint": {
"uriMatchDefaultStrategyHint": {
"message": "URI match detection is how Bitwarden identifies autofill suggestions.",
"description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item."
},

View File

@@ -71,17 +71,6 @@ export type OrganizationReportApplication = {
reviewedDate: Date | null;
};
/**
* Detailed information for a new (unreviewed) application.
* Used by the new applications dialog to display metrics per application.
*/
export type NewApplicationDetail = {
applicationName: string;
atRiskPasswordCount: number;
passwordCount: number;
atRiskMemberCount: number;
};
/**
* Report details for an application
* uri. Has the at risk, password, and member information

View File

@@ -52,11 +52,11 @@ import { RiskInsightsEnrichedData } from "../../models/report-data-service.types
import {
CipherHealthReport,
MemberDetails,
NewApplicationDetail,
OrganizationReportApplication,
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";
@@ -99,50 +99,26 @@ export class RiskInsightsOrchestratorService {
enrichedReportData$ = this._enrichedReportDataSubject.asObservable();
// New applications that haven't been reviewed (reviewedDate === null)
// Returns full application details for display in the new applications dialog
newApplications$: Observable<NewApplicationDetail[]> = this.rawReportData$.pipe(
newApplications$: Observable<ApplicationHealthReportDetail[]> = this.rawReportData$.pipe(
map((reportState) => {
if (!reportState.data?.applicationData || !reportState.data?.reportData) {
return [];
}
const reportApplications = reportState.data?.applicationData || [];
// Get applications that haven't been reviewed
const unreviewedApps = reportState.data.applicationData.filter(
(app) => app.reviewedDate === null,
);
// Map to NewApplicationDetail with full data from reportData
return unreviewedApps
.map((app) => {
// Find matching report data for this application
const reportDetail = reportState.data!.reportData.find(
(report) => report.applicationName === app.applicationName,
);
// Skip if no matching report detail found
if (!reportDetail) {
return null;
}
return {
applicationName: app.applicationName,
atRiskPasswordCount: reportDetail.atRiskPasswordCount,
passwordCount: reportDetail.passwordCount,
atRiskMemberCount: reportDetail.atRiskMemberCount,
} as NewApplicationDetail;
})
.filter((app): app is NewApplicationDetail => app !== null);
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(
(prevApp, i) =>
prevApp.applicationName === curr[i].applicationName &&
prevApp.atRiskPasswordCount === curr[i].atRiskPasswordCount &&
prevApp.passwordCount === curr[i].passwordCount &&
prevApp.atRiskMemberCount === curr[i].atRiskMemberCount,
(app, i) =>
app.applicationName === curr[i].applicationName &&
app.atRiskPasswordCount === curr[i].atRiskPasswordCount,
);
}),
shareReplay({ bufferSize: 1, refCount: true }),
@@ -367,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,
);
@@ -478,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),
@@ -499,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;
@@ -519,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(
{
@@ -541,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}`,
);
@@ -556,31 +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),
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" });
}),
);
},
);
// 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" });
}),
);
}),
);
}
@@ -792,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

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

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,11 +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,
NewApplicationDetail,
ApplicationHealthReportDetail,
ReportStatus,
RiskInsightsDataService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
@@ -14,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 +22,7 @@ import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.co
import { ActivityCardComponent } from "./activity-card.component";
import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component";
import { NewApplicationsDialogComponent } from "./new-applications-dialog.component";
import { NewApplicationsDialogComponent } from "./application-review-dialog/new-applications-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -41,7 +42,7 @@ export class AllActivityComponent implements OnInit {
totalCriticalAppsCount = 0;
totalCriticalAppsAtRiskCount = 0;
newApplicationsCount = 0;
newApplications: NewApplicationDetail[] = [];
newApplications: ApplicationHealthReportDetail[] = [];
passwordChangeMetricHasProgressBar = false;
allAppsHaveReviewDate = false;
isAllCaughtUp = false;
@@ -128,7 +129,7 @@ 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) {
@@ -141,25 +142,25 @@ export class AllActivityComponent implements OnInit {
// organizationId is populated via async route subscription.
const dialogRef = NewApplicationsDialogComponent.open(this.dialogService, {
newApplications: this.newApplications,
organizationId: organizationId as any,
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,84 @@
<bit-dialog [dialogSize]="'large'">
<span bitDialogTitle>
{{
currentView() === DialogView.SelectApplications
? ("prioritizeCriticalApplications" | i18n)
: ("assignTasksToMembers" | i18n)
}}
</span>
<div bitDialogContent>
@if (currentView() === DialogView.SelectApplications) {
<dirt-review-applications-view
[applications]="getApplications()"
[selectedApplications]="selectedApplications()"
(onToggleSelection)="toggleSelection($event)"
(onToggleAll)="toggleAll()"
></dirt-review-applications-view>
}
@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>

View File

@@ -0,0 +1,276 @@
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
Inject,
inject,
signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { from, switchMap } from "rxjs";
import {
ApplicationHealthReportDetail,
ApplicationHealthReportDetailEnriched,
OrganizationReportApplication,
RiskInsightsDataService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
ButtonModule,
DIALOG_DATA,
DialogModule,
DialogRef,
DialogService,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service";
import { AssignTasksViewComponent } from "./assign-tasks-view.component";
import { ReviewApplicationsViewComponent } from "./review-applications-view.component";
export interface NewApplicationsDialogData {
newApplications: ApplicationHealthReportDetail[];
/**
* Organization ID is passed via dialog data instead of being retrieved from route params.
* This ensures organizationId is available immediately when the dialog opens,
* preventing async timing issues where user clicks "Mark as critical" before
* the route subscription has fired.
*/
organizationId: OrganizationId;
}
/**
* View states for dialog navigation
* Using const object pattern per ADR-0025 (Deprecate TypeScript Enums)
*/
export const DialogView = Object.freeze({
SelectApplications: "select",
AssignTasks: "assign",
} as const);
export type DialogView = (typeof DialogView)[keyof typeof DialogView];
// Possible results for closing the dialog
export const NewApplicationsDialogResultType = Object.freeze({
Close: "close",
Complete: "complete",
} as const);
export type NewApplicationsDialogResultType =
(typeof NewApplicationsDialogResultType)[keyof typeof NewApplicationsDialogResultType];
@Component({
selector: "dirt-new-applications-dialog",
templateUrl: "./new-applications-dialog.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ButtonModule,
DialogModule,
TypographyModule,
I18nPipe,
AssignTasksViewComponent,
ReviewApplicationsViewComponent,
],
})
export class NewApplicationsDialogComponent {
destroyRef = inject(DestroyRef);
// View state management
protected readonly currentView = signal<DialogView>(DialogView.SelectApplications);
// Expose DialogView constants to template
protected readonly DialogView = DialogView;
// Review new applications view
// Applications selected to save as critical applications
protected readonly selectedApplications = signal<Set<string>>(new Set());
// Assign tasks variables
readonly criticalApplicationsCount = signal<number>(0);
readonly totalApplicationsCount = signal<number>(0);
readonly atRiskCriticalMembersCount = signal<number>(0);
readonly saving = signal<boolean>(false);
// Loading states
protected readonly markingAsCritical = signal<boolean>(false);
constructor(
@Inject(DIALOG_DATA) protected dialogParams: NewApplicationsDialogData,
private dialogRef: DialogRef<NewApplicationsDialogResultType>,
private dataService: RiskInsightsDataService,
private toastService: ToastService,
private i18nService: I18nService,
private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
private logService: LogService,
) {}
/**
* Opens the new applications dialog
* @param dialogService The dialog service instance
* @param data Dialog data containing the list of new applications and organizationId
* @returns Dialog reference
*/
static open(dialogService: DialogService, data: NewApplicationsDialogData) {
return dialogService.open<boolean | undefined, NewApplicationsDialogData>(
NewApplicationsDialogComponent,
{
data,
},
);
}
getApplications() {
return this.dialogParams.newApplications;
}
/**
* Toggles the selection state of an application.
* @param applicationName The application to toggle
*/
toggleSelection(applicationName: string) {
this.selectedApplications.update((current) => {
const temp = new Set(current);
if (temp.has(applicationName)) {
temp.delete(applicationName);
} else {
temp.add(applicationName);
}
return temp;
});
}
/**
* Toggles the selection state of all applications.
* If all are selected, unselect all. Otherwise, select all.
*/
toggleAll() {
const allApplicationNames = this.dialogParams.newApplications.map((app) => app.applicationName);
const allSelected = this.selectedApplications().size === allApplicationNames.length;
this.selectedApplications.update(() => {
return allSelected ? new Set() : new Set(allApplicationNames);
});
}
handleMarkAsCritical() {
if (this.markingAsCritical() || this.saving()) {
return; // Prevent action if already processing
}
this.markingAsCritical.set(true);
const onlyNewCriticalApplications = this.dialogParams.newApplications.filter((newApp) =>
this.selectedApplications().has(newApp.applicationName),
);
const atRiskCriticalMembersCount = getUniqueMembers(
onlyNewCriticalApplications.flatMap((x) => x.atRiskMemberDetails),
).length;
this.atRiskCriticalMembersCount.set(atRiskCriticalMembersCount);
this.currentView.set(DialogView.AssignTasks);
this.markingAsCritical.set(false);
}
/**
* Handles the assign tasks button click
*/
protected handleAssignTasks() {
if (this.saving()) {
return; // Prevent double-click
}
this.saving.set(true);
// Create updated organization report application types with new review date
// and critical marking based on selected applications
const newReviewDate = new Date();
const updatedApplications: OrganizationReportApplication[] =
this.dialogParams.newApplications.map((app) => ({
applicationName: app.applicationName,
isCritical: this.selectedApplications().has(app.applicationName),
reviewedDate: newReviewDate,
}));
// Save the application review dates and critical markings
this.dataService
.saveApplicationReviewStatus(updatedApplications)
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap((updatedState) => {
// After initial save is complete, created the assigned tasks
// for at risk passwords
const updatedStateApplicationData = updatedState?.data?.applicationData || [];
// Manual enrich for type matching
// TODO Consolidate in model updates
const manualEnrichedApplications =
updatedState?.data?.reportData.map(
(application): ApplicationHealthReportDetailEnriched => ({
...application,
isMarkedAsCritical: updatedStateApplicationData.some(
(a) => a.applicationName == application.applicationName && a.isCritical,
),
}),
) || [];
return from(
this.accessIntelligenceSecurityTasksService.assignTasks(
this.dialogParams.organizationId,
manualEnrichedApplications,
),
);
}),
)
.subscribe({
next: () => {
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("applicationReviewSaved"),
message: this.i18nService.t("newApplicationsReviewed"),
});
this.saving.set(false);
this.handleAssigningCompleted();
},
error: (error: unknown) => {
this.logService.error(
"[NewApplicationsDialog] Failed to save application review or assign tasks",
error,
);
this.saving.set(false);
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorSavingReviewStatus"),
message: this.i18nService.t("pleaseTryAgain"),
});
},
});
}
/**
* Closes the dialog when the "Cancel" button is selected
*/
handleCancel() {
this.dialogRef.close(NewApplicationsDialogResultType.Close);
}
/**
* Handles the tasksAssigned event from the embedded component.
* Closes the dialog with success indicator.
*/
protected handleAssigningCompleted = () => {
// Tasks were successfully assigned - close dialog
this.dialogRef.close(NewApplicationsDialogResultType.Complete);
};
/**
* Handles the back event from the embedded component.
* Returns to the select applications view.
*/
protected onBack = () => {
this.currentView.set(DialogView.SelectApplications);
};
}

View File

@@ -0,0 +1,94 @@
<div class="tw-space-y-3">
<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>
<bit-search
[placeholder]="'searchApps' | i18n"
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged($event)"
></bit-search>
<div class="tw-overflow-x-auto">
<table class="tw-w-full tw-border-collapse">
<thead>
<tr class="tw-border-b tw-border-secondary-300">
<th bitTypography="body2" class="tw-text-left tw-py-3 tw-px-2 tw-w-12">
<button
type="button"
class="tw-bg-transparent tw-border-0 tw-p-0 tw-cursor-pointer"
(click)="toggleAll()"
[attr.aria-label]="isAllSelected() ? ('unselectAll' | i18n) : ('selectAll' | i18n)"
>
<i
class="bwi tw-text-muted"
[ngClass]="isAllSelected() ? 'bwi-star-f' : 'bwi-star'"
aria-hidden="true"
></i>
</button>
</th>
<th bitTypography="body2" class="tw-text-left tw-py-3 tw-px-2 tw-font-semibold">
{{ "application" | i18n }}
</th>
<th bitTypography="body2" class="tw-text-right tw-py-3 tw-px-2 tw-font-semibold">
{{ "atRiskPasswords" | i18n }}
</th>
<th bitTypography="body2" class="tw-text-right tw-py-3 tw-px-2 tw-font-semibold">
{{ "totalPasswords" | i18n }}
</th>
<th bitTypography="body2" class="tw-text-right tw-py-3 tw-px-2 tw-font-semibold">
{{ "atRiskMembers" | i18n }}
</th>
</tr>
</thead>
<tbody>
@for (app of filteredApplications(); track app.applicationName) {
<tr class="tw-border-b tw-border-secondary-300 hover:tw-bg-background-alt">
<td class="tw-py-3 tw-px-2">
<button
type="button"
class="tw-bg-transparent tw-border-0 tw-p-0 tw-cursor-pointer"
(click)="toggleSelection(app.applicationName)"
[attr.aria-label]="
selectedApplications().has(app.applicationName)
? ('unselectApplication' | i18n)
: ('selectApplication' | i18n)
"
>
<i
class="bwi tw-text-muted"
[ngClass]="
selectedApplications().has(app.applicationName) ? 'bwi-star-f' : 'bwi-star'
"
aria-hidden="true"
></i>
</button>
</td>
<td bitTypography="body1" class="tw-py-3 tw-px-2">
<div class="tw-flex tw-items-center tw-gap-2">
<i class="bwi bwi-globe tw-text-muted" aria-hidden="true"></i>
<span>{{ app.applicationName }}</span>
</div>
</td>
<td bitTypography="body1" class="tw-py-3 tw-px-2 tw-text-right">
{{ app.atRiskPasswordCount }}
</td>
<td bitTypography="body1" class="tw-py-3 tw-px-2 tw-text-right">
{{ app.passwordCount }}
</td>
<td bitTypography="body1" class="tw-py-3 tw-px-2 tw-text-right">
{{ app.atRiskMemberCount }}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,61 @@
import { CommonModule } from "@angular/common";
import { Component, input, output, ChangeDetectionStrategy, signal, computed } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { ApplicationHealthReportDetail } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { ButtonModule, DialogModule, SearchModule, TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "dirt-review-applications-view",
templateUrl: "./review-applications-view.component.html",
imports: [
CommonModule,
ButtonModule,
DialogModule,
FormsModule,
SearchModule,
TypographyModule,
I18nPipe,
],
})
export class ReviewApplicationsViewComponent {
readonly applications = input.required<ApplicationHealthReportDetail[]>();
readonly selectedApplications = input.required<Set<string>>();
protected readonly searchText = signal<string>("");
// Filter applications based on search text
protected readonly filteredApplications = computed(() => {
const search = this.searchText().toLowerCase();
if (!search) {
return this.applications();
}
return this.applications().filter((app) => app.applicationName.toLowerCase().includes(search));
});
// Return the selected applications from the view
onToggleSelection = output<string>();
onToggleAll = output<void>();
toggleSelection(applicationName: string): void {
this.onToggleSelection.emit(applicationName);
}
toggleAll(): void {
this.onToggleAll.emit();
}
isAllSelected(): boolean {
const filtered = this.filteredApplications();
return (
filtered.length > 0 &&
filtered.every((app) => this.selectedApplications().has(app.applicationName))
);
}
onSearchTextChanged(searchText: string): void {
this.searchText.set(searchText);
}
}

View File

@@ -1,128 +0,0 @@
<bit-dialog [dialogSize]="'default'">
<span bitDialogTitle>
{{
currentView === DialogView.SelectApplications
? ("prioritizeCriticalApplications" | i18n)
: ("assignTasksToMembers" | i18n)
}}
</span>
<div bitDialogContent>
<!-- View 1: Select Applications -->
@if (currentView === DialogView.SelectApplications) {
<!-- Instructional text -->
<div class="tw-mb-6">
<p bitTypography="body1" class="tw-mb-4">
{{ "selectCriticalApplicationsDescription" | i18n }}
</p>
<div class="tw-flex tw-items-center tw-gap-2">
<i class="bwi bwi-star tw-text-muted" aria-hidden="true"></i>
<span class="tw-text-xs tw-text-muted">
{{ "clickIconToMarkCritical" | i18n }}
</span>
</div>
</div>
<div class="tw-overflow-x-auto">
<table class="tw-w-full tw-border-collapse">
<thead>
<tr class="tw-border-b tw-border-secondary-300">
<th bitTypography="body2" class="tw-text-left tw-py-3 tw-px-2 tw-w-12"></th>
<th bitTypography="body2" class="tw-text-left tw-py-3 tw-px-2 tw-font-semibold">
{{ "application" | i18n }}
</th>
<th bitTypography="body2" class="tw-text-right tw-py-3 tw-px-2 tw-font-semibold">
{{ "atRiskItems" | i18n }}
</th>
<th bitTypography="body2" class="tw-text-right tw-py-3 tw-px-2 tw-font-semibold">
{{ "totalItems" | i18n }}
</th>
<th bitTypography="body2" class="tw-text-right tw-py-3 tw-px-2 tw-font-semibold">
{{ "membersAffected" | i18n }}
</th>
</tr>
</thead>
<tbody>
@for (app of newApplications; track app.applicationName) {
<tr class="tw-border-b tw-border-secondary-300 hover:tw-bg-background-alt">
<td class="tw-py-3 tw-px-2">
<button
type="button"
class="tw-bg-transparent tw-border-0 tw-p-0 tw-cursor-pointer"
(click)="toggleSelection(app.applicationName)"
[attr.aria-label]="
isSelected(app.applicationName)
? ('unselectApplication' | i18n)
: ('selectApplication' | i18n)
"
>
<i
class="bwi tw-text-muted"
[ngClass]="isSelected(app.applicationName) ? 'bwi-star-f' : 'bwi-star'"
aria-hidden="true"
></i>
</button>
</td>
<td bitTypography="body1" class="tw-py-3 tw-px-2">
<div class="tw-flex tw-items-center tw-gap-2">
<i class="bwi bwi-globe tw-text-muted" aria-hidden="true"></i>
<span>{{ app.applicationName }}</span>
</div>
</td>
<td bitTypography="body1" class="tw-py-3 tw-px-2 tw-text-right">
{{ app.atRiskPasswordCount }}
</td>
<td bitTypography="body1" class="tw-py-3 tw-px-2 tw-text-right tw-text-muted">
{{ app.passwordCount }}
</td>
<td bitTypography="body1" class="tw-py-3 tw-px-2 tw-text-right tw-text-muted">
{{ app.atRiskMemberCount }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- View 2: Assign Tasks (Embedded Component) -->
@if (currentView === DialogView.AssignTasks) {
<dirt-assign-tasks-view
[selectedApplicationsCount]="selectedApplications.size"
[organizationId]="organizationId"
(tasksAssigned)="onTasksAssigned()"
(back)="onBack()"
>
</dirt-assign-tasks-view>
}
</div>
<!-- Footer buttons only shown for first view -->
<!-- Second view has buttons in embedded component -->
@if (currentView === DialogView.SelectApplications) {
<ng-container bitDialogFooter>
<button
type="button"
bitButton
size="small"
buttonType="primary"
(click)="onMarkAsCritical()"
[disabled]="isCalculatingTasks"
[loading]="isCalculatingTasks"
[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>
}
</bit-dialog>

View File

@@ -1,243 +0,0 @@
import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core";
import { firstValueFrom } from "rxjs";
import {
AllActivitiesService,
NewApplicationDetail,
RiskInsightsDataService,
SecurityTasksApiService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
ButtonModule,
DialogModule,
DialogRef,
DialogService,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { AssignTasksViewComponent } from "./assign-tasks-view.component";
export interface NewApplicationsDialogData {
newApplications: NewApplicationDetail[];
/**
* Organization ID is passed via dialog data instead of being retrieved from route params.
* This ensures organizationId is available immediately when the dialog opens,
* preventing async timing issues where user clicks "Mark as critical" before
* the route subscription has fired.
*/
organizationId: OrganizationId;
}
/**
* View states for dialog navigation
* Using const object pattern per ADR-0025 (Deprecate TypeScript Enums)
*/
export const DialogView = Object.freeze({
SelectApplications: "select",
AssignTasks: "assign",
} as const);
export type DialogView = (typeof DialogView)[keyof typeof DialogView];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./new-applications-dialog.component.html",
imports: [
CommonModule,
ButtonModule,
DialogModule,
TypographyModule,
I18nPipe,
AssignTasksViewComponent,
],
})
export class NewApplicationsDialogComponent {
protected newApplications: NewApplicationDetail[] = [];
protected selectedApplications: Set<string> = new Set<string>();
// View state management
protected currentView: DialogView = DialogView.SelectApplications;
// Expose DialogView constants to template
protected readonly DialogView = DialogView;
// Loading states
protected isCalculatingTasks = false;
private dialogRef = inject(DialogRef<boolean | undefined>);
private dataService = inject(RiskInsightsDataService);
private toastService = inject(ToastService);
private i18nService = inject(I18nService);
private logService = inject(LogService);
private allActivitiesService = inject(AllActivitiesService);
private securityTasksApiService = inject(SecurityTasksApiService);
/**
* Organization ID set synchronously by static open() method from dialog data.
* Must be available immediately (not async) because checkForTasksToAssign()
* needs it when user clicks "Mark as critical" button.
* Previous implementation using route subscription had timing issues.
*/
organizationId: OrganizationId = "" as OrganizationId;
/**
* Opens the new applications dialog
* @param dialogService The dialog service instance
* @param data Dialog data containing the list of new applications and organizationId
* @returns Dialog reference
*/
static open(dialogService: DialogService, data: NewApplicationsDialogData) {
const ref = dialogService.open<boolean | undefined, NewApplicationsDialogData>(
NewApplicationsDialogComponent,
{
data,
},
);
// Set the component's data after opening
// Important: organizationId is set synchronously here, not via async route subscription.
// This prevents race conditions where user clicks "Mark as critical" before
// organizationId is populated, which would cause checkForTasksToAssign() to fail.
const instance = ref.componentInstance as NewApplicationsDialogComponent;
if (instance) {
instance.newApplications = data.newApplications;
instance.organizationId = data.organizationId;
}
return ref;
}
/**
* Toggles the selection state of an application.
* @param applicationName The application to toggle
*/
toggleSelection = (applicationName: string) => {
if (this.selectedApplications.has(applicationName)) {
this.selectedApplications.delete(applicationName);
} else {
this.selectedApplications.add(applicationName);
}
};
/**
* Checks if an application is currently selected.
* @param applicationName The application to check
* @returns True if selected, false otherwise
*/
isSelected = (applicationName: string): boolean => {
return this.selectedApplications.has(applicationName);
};
/**
* Handles the "Mark as Critical" button click.
* Saves review status and checks if there are tasks to assign.
* If tasks exist, shows assign tasks view; otherwise closes dialog with success.
*/
onMarkAsCritical = async () => {
if (this.isCalculatingTasks) {
return; // Prevent double-click
}
this.isCalculatingTasks = true;
const selectedCriticalApps = Array.from(this.selectedApplications);
try {
await firstValueFrom(this.dataService.saveApplicationReviewStatus(selectedCriticalApps));
// Check if there are tasks to assign
if (selectedCriticalApps.length > 0) {
const hasTasksToAssign = await this.checkForTasksToAssign();
if (hasTasksToAssign) {
// Transition to assign tasks view
this.currentView = DialogView.AssignTasks;
return; // Don't close dialog or show toast yet
}
}
// No critical apps selected OR no tasks to assign - show success and close
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("applicationReviewSaved"),
message:
selectedCriticalApps.length > 0
? this.i18nService.t("applicationsMarkedAsCritical", selectedCriticalApps.length)
: this.i18nService.t("newApplicationsReviewed"),
});
this.dialogRef.close(true);
} catch {
this.logService.error("[NewApplicationsDialog] Failed to save review status");
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorSavingReviewStatus"),
message: this.i18nService.t("pleaseTryAgain"),
});
} finally {
this.isCalculatingTasks = false;
}
};
/**
* Checks if there are tasks to assign for the selected critical applications.
* Returns true if tasks can be assigned, false otherwise.
*/
private async checkForTasksToAssign(): Promise<boolean> {
try {
this.logService.info(
`[NewApplicationsDialog] checkForTasksToAssign - organizationId: ${this.organizationId}`,
);
if (!this.organizationId) {
this.logService.warning("[NewApplicationsDialog] organizationId is not set yet");
return false;
}
const taskMetrics = await firstValueFrom(
this.securityTasksApiService.getTaskMetrics(this.organizationId),
);
this.logService.info(
`[NewApplicationsDialog] taskMetrics: totalTasks=${taskMetrics.totalTasks}, completedTasks=${taskMetrics.completedTasks}`,
);
const atRiskPasswordsCount = await firstValueFrom(
this.allActivitiesService.atRiskPasswordsCount$,
);
this.logService.info(`[NewApplicationsDialog] atRiskPasswordsCount: ${atRiskPasswordsCount}`);
const canAssignTasks = atRiskPasswordsCount > taskMetrics.totalTasks;
const newTasksCount = canAssignTasks ? atRiskPasswordsCount - taskMetrics.totalTasks : 0;
this.logService.info(
`[NewApplicationsDialog] canAssignTasks: ${canAssignTasks}, newTasksCount: ${newTasksCount}, returning: ${canAssignTasks && newTasksCount > 0}`,
);
return canAssignTasks && newTasksCount > 0;
} catch (error) {
this.logService.error("[NewApplicationsDialog] Failed to check for tasks", error);
return false;
}
}
/**
* Handles the tasksAssigned event from the embedded component.
* Closes the dialog with success indicator.
*/
protected onTasksAssigned = () => {
// Tasks were successfully assigned - close dialog
this.dialogRef.close(true);
};
/**
* Handles the back event from the embedded component.
* Returns to the select applications view.
*/
protected onBack = () => {
this.currentView = DialogView.SelectApplications;
};
}

View File

@@ -91,6 +91,7 @@ export class AllApplicationsComponent implements OnInit {
markAppsAsCritical = async () => {
this.markingAsCritical = true;
const count = this.selectedUrls.size;
this.dataService
.saveCriticalApplications(Array.from(this.selectedUrls))
@@ -100,7 +101,7 @@ export class AllApplicationsComponent implements OnInit {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"),
message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()),
});
this.selectedUrls.clear();
this.markingAsCritical = false;

View File

@@ -14,10 +14,4 @@ export abstract class AuditService {
* @returns A promise that resolves to an array of BreachAccountResponse objects.
*/
abstract breachedAccounts: (username: string) => Promise<BreachAccountResponse[]>;
/**
* Checks if a domain is known for phishing.
* @param domain The domain to check.
* @returns A promise that resolves to a boolean indicating if the domain is known for phishing.
*/
abstract getKnownPhishingDomains: () => Promise<string[]>;
}

View File

@@ -1,8 +1,13 @@
import { map, Observable } from "rxjs";
import { combineLatest, map, Observable } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserId } from "../../../types/guid";
import { PolicyType } from "../../enums";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
import { PolicyService } from "../policy/policy.service.abstraction";
export function canAccessVaultTab(org: Organization): boolean {
return org.canViewAllCollections;
@@ -51,6 +56,17 @@ export function canAccessOrgAdmin(org: Organization): boolean {
);
}
export function canAccessEmergencyAccess(
userId: UserId,
configService: ConfigService,
policyService: PolicyService,
) {
return combineLatest([
configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
policyService.policiesByType$(PolicyType.AutoConfirm, userId),
]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled)));
}
/**
* @deprecated Please use the general `getById` custom rxjs operator instead.
*/

View File

@@ -554,6 +554,77 @@ describe("PolicyService", () => {
expect(result).toBe(false);
});
describe("SingleOrg policy exemptions", () => {
it("returns true for SingleOrg policy when AutoConfirm is enabled, even for users who can manage policies", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org6", PolicyType.SingleOrg, true),
policyData("policy2", "org6", PolicyType.AutoConfirm, true),
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
);
expect(result).toBe(true);
});
it("returns false for SingleOrg policy when user can manage policies and AutoConfirm is not enabled", async () => {
singleUserState.nextState(
arrayToRecord([policyData("policy1", "org6", PolicyType.SingleOrg, true)]),
);
const result = await firstValueFrom(
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
);
expect(result).toBe(false);
});
it("returns false for SingleOrg policy when user can manage policies and AutoConfirm is disabled", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org6", PolicyType.SingleOrg, true),
policyData("policy2", "org6", PolicyType.AutoConfirm, false),
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
);
expect(result).toBe(false);
});
it("returns true for SingleOrg policy for regular users when AutoConfirm is not enabled", async () => {
singleUserState.nextState(
arrayToRecord([policyData("policy1", "org1", PolicyType.SingleOrg, true)]),
);
const result = await firstValueFrom(
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
);
expect(result).toBe(true);
});
it("returns true for SingleOrg policy when AutoConfirm is enabled in a different organization", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org6", PolicyType.SingleOrg, true),
policyData("policy2", "org1", PolicyType.AutoConfirm, true),
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
);
expect(result).toBe(false);
});
});
});
describe("combinePoliciesIntoMasterPasswordPolicyOptions", () => {

View File

@@ -40,18 +40,16 @@ export class DefaultPolicyService implements PolicyService {
}
policiesByType$(policyType: PolicyType, userId: UserId) {
const filteredPolicies$ = this.policies$(userId).pipe(
map((policies) => policies.filter((p) => p.type === policyType)),
);
if (!userId) {
throw new Error("No userId provided");
}
const allPolicies$ = this.policies$(userId);
const organizations$ = this.organizationService.organizations$(userId);
return combineLatest([filteredPolicies$, organizations$]).pipe(
return combineLatest([allPolicies$, organizations$]).pipe(
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
map((policies) => policies.filter((p) => p.type === policyType)),
);
}
@@ -77,7 +75,7 @@ export class DefaultPolicyService implements PolicyService {
policy.enabled &&
organization.status >= OrganizationUserStatusType.Accepted &&
organization.usePolicies &&
!this.isExemptFromPolicy(policy.type, organization)
!this.isExemptFromPolicy(policy.type, organization, policies)
);
});
}
@@ -265,7 +263,11 @@ export class DefaultPolicyService implements PolicyService {
* Determines whether an orgUser is exempt from a specific policy because of their role
* Generally orgUsers who can manage policies are exempt from them, but some policies are stricter
*/
private isExemptFromPolicy(policyType: PolicyType, organization: Organization) {
private isExemptFromPolicy(
policyType: PolicyType,
organization: Organization,
allPolicies: Policy[],
) {
switch (policyType) {
case PolicyType.MaximumVaultTimeout:
// Max Vault Timeout applies to everyone except owners
@@ -286,6 +288,14 @@ export class DefaultPolicyService implements PolicyService {
case PolicyType.OrganizationDataOwnership:
// organization data ownership policy applies to everyone except admins and owners
return organization.isAdmin;
case PolicyType.SingleOrg:
// Check if AutoConfirm policy is enabled for this organization
return allPolicies.find(
(p) =>
p.organizationId === organization.id && p.type === PolicyType.AutoConfirm && p.enabled,
)
? false
: organization.canManagePolicies;
default:
return organization.canManagePolicies;
}

View File

@@ -166,7 +166,7 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
if (!policy?.enabled || policy?.data == null) {
return null;
}
const data = policy.data?.defaultUriMatchStrategy;
const data = policy.data?.uriMatchDetection;
// Validate that data is a valid UriMatchStrategy value
return Object.values(UriMatchStrategy).includes(data) ? data : null;
}),

View File

@@ -37,6 +37,7 @@ export enum FeatureFlag {
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2",
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
@@ -124,6 +125,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
[FeatureFlag.WindowsBiometricsV2]: FALSE,
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,

View File

@@ -80,9 +80,4 @@ export class AuditService implements AuditServiceAbstraction {
throw new Error();
}
}
async getKnownPhishingDomains(): Promise<string[]> {
const response = await this.apiService.send("GET", "/phishing-domains", null, true, true);
return response as string[];
}
}

View File

@@ -107,6 +107,10 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne
web: "disk-local",
});
// DIRT
export const PHISHING_DETECTION_DISK = new StateDefinition("phishingDetection", "disk");
// Platform
export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", {

16
package-lock.json generated
View File

@@ -134,7 +134,7 @@
"copy-webpack-plugin": "13.0.0",
"cross-env": "10.1.0",
"css-loader": "7.1.2",
"electron": "36.9.3",
"electron": "37.7.0",
"electron-builder": "26.0.12",
"electron-log": "5.4.0",
"electron-reload": "2.0.0-alpha.1",
@@ -194,11 +194,11 @@
},
"apps/browser": {
"name": "@bitwarden/browser",
"version": "2025.10.1"
"version": "2025.11.0"
},
"apps/cli": {
"name": "@bitwarden/cli",
"version": "2025.10.1",
"version": "2025.11.0",
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"@koa/multer": "4.0.0",
@@ -280,7 +280,7 @@
},
"apps/desktop": {
"name": "@bitwarden/desktop",
"version": "2025.10.2",
"version": "2025.11.0",
"hasInstallScript": true,
"license": "GPL-3.0"
},
@@ -294,7 +294,7 @@
},
"apps/web": {
"name": "@bitwarden/web-vault",
"version": "2025.10.1"
"version": "2025.11.0"
},
"libs/admin-console": {
"name": "@bitwarden/admin-console",
@@ -20865,9 +20865,9 @@
}
},
"node_modules/electron": {
"version": "36.9.3",
"resolved": "https://registry.npmjs.org/electron/-/electron-36.9.3.tgz",
"integrity": "sha512-eR5yswsA55zVTPDEIA/PSdVNBLOp0q0Wsavgx0S3BmJYOqKoH1gqzS+hggf0/aY5OvUjVNSHiJJA1VsB5aJUug==",
"version": "37.7.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-37.7.0.tgz",
"integrity": "sha512-LBzvfrS0aalynOsnC11AD7zeoU8eOois090mzLpQM3K8yZ2N04i2ZW9qmHOTFLrXlKvrwRc7EbyQf1u8XHMl6Q==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",

View File

@@ -97,7 +97,7 @@
"copy-webpack-plugin": "13.0.0",
"cross-env": "10.1.0",
"css-loader": "7.1.2",
"electron": "36.9.3",
"electron": "37.7.0",
"electron-builder": "26.0.12",
"electron-log": "5.4.0",
"electron-reload": "2.0.0-alpha.1",