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:
167
.github/workflows/sdk-breaking-change-check.yml
vendored
Normal file
167
.github/workflows/sdk-breaking-change-check.yml
vendored
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
2
apps/desktop/desktop_native/Cargo.lock
generated
2
apps/desktop/desktop_native/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as WindowsBiometricsSystem } from "./os-biometrics-windows.service";
|
||||
export { default as LinuxBiometricsSystem } from "./os-biometrics-linux.service";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -19,6 +19,9 @@ export enum BiometricAction {
|
||||
|
||||
EnableWindowsV2 = "enableWindowsV2",
|
||||
IsWindowsV2Enabled = "isWindowsV2Enabled",
|
||||
|
||||
EnableLinuxV2 = "enableLinuxV2",
|
||||
IsLinuxV2Enabled = "isLinuxV2Enabled",
|
||||
}
|
||||
|
||||
export type BiometricMessage =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{{ "autoConfirmSingleOrgRequired" | i18n }}
|
||||
</span>
|
||||
}
|
||||
{{ "autoConfirmSingleOrgRequiredDescription" | i18n }}
|
||||
{{ "autoConfirmSingleOrgRequiredDesc" | i18n }}
|
||||
</li>
|
||||
|
||||
<li>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 member’s 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."
|
||||
},
|
||||
|
||||
BIN
apps/web/src/videos/access-intelligence-assign-tasks-dark.mp4
Normal file
BIN
apps/web/src/videos/access-intelligence-assign-tasks-dark.mp4
Normal file
Binary file not shown.
BIN
apps/web/src/videos/access-intelligence-assign-tasks.mp4
Normal file
BIN
apps/web/src/videos/access-intelligence-assign-tasks.mp4
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
16
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user