mirror of
https://github.com/bitwarden/browser
synced 2026-02-05 03:03:26 +00:00
Merge branch 'main' into add-risk-over-time-chart
This commit is contained in:
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
|
||||
21
.storybook/preview-head.html
Normal file
21
.storybook/preview-head.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!-- preload the inter font to avoid a flash of fallback font when first loading storybook -->
|
||||
<!-- href matches the inter build artifact from webpack -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="/inter.0336a89fb4e7fc1d.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<!-- load inter font from the source cdn so that chromatic snapshots render in the correct font -->
|
||||
<link rel="preconnect" href="https://rsms.me/" />
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
|
||||
<!-- manually specify inter as the font here for chromatic snapshots -->
|
||||
<style>
|
||||
:root {
|
||||
font-family: Inter;
|
||||
font-weight: 100 900;
|
||||
}
|
||||
</style>
|
||||
@@ -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",
|
||||
|
||||
@@ -1523,12 +1523,6 @@
|
||||
"enableAutoBiometricsPrompt": {
|
||||
"message": "Ask for biometrics on launch"
|
||||
},
|
||||
"premiumRequired": {
|
||||
"message": "Premium required"
|
||||
},
|
||||
"premiumRequiredDesc": {
|
||||
"message": "A Premium membership is required to use this feature."
|
||||
},
|
||||
"authenticationTimeout": {
|
||||
"message": "Authentication timeout"
|
||||
},
|
||||
@@ -5772,6 +5766,30 @@
|
||||
"atRiskLoginsSecured": {
|
||||
"message": "Great job securing your at-risk logins!"
|
||||
},
|
||||
"upgradeNow": {
|
||||
"message": "Upgrade now"
|
||||
},
|
||||
"builtInAuthenticator": {
|
||||
"message": "Built-in authenticator"
|
||||
},
|
||||
"secureFileStorage": {
|
||||
"message": "Secure file storage"
|
||||
},
|
||||
"emergencyAccess": {
|
||||
"message": "Emergency access"
|
||||
},
|
||||
"breachMonitoring": {
|
||||
"message": "Breach monitoring"
|
||||
},
|
||||
"andMoreFeatures": {
|
||||
"message": "And more!"
|
||||
},
|
||||
"planDescPremium": {
|
||||
"message": "Complete online security"
|
||||
},
|
||||
"upgradeToPremium": {
|
||||
"message": "Upgrade to Premium"
|
||||
},
|
||||
"settingDisabledByPolicy": {
|
||||
"message": "This setting is disabled by your organization's policy.",
|
||||
"description": "This hint text is displayed when a user setting is disabled due to an organization policy."
|
||||
|
||||
@@ -144,17 +144,17 @@ export const border = {
|
||||
export const typography = {
|
||||
body1: `
|
||||
line-height: 24px;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
`,
|
||||
body2: `
|
||||
line-height: 20px;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 14px;
|
||||
`,
|
||||
helperMedium: `
|
||||
line-height: 16px;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 12px;
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ const baseTextStyles = css`
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 24px;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ const baseTextStyles = css`
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 24px;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const notificationHeaderMessageStyles = (theme: Theme) => css`
|
||||
line-height: 28px;
|
||||
white-space: nowrap;
|
||||
color: ${themes[theme].text.main};
|
||||
font-family: Roboto, sans-serif;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
@@ -32,6 +32,17 @@ import { InlineMenuFieldQualificationService } from "./inline-menu-field-qualifi
|
||||
|
||||
const defaultWindowReadyState = document.readyState;
|
||||
const defaultDocumentVisibilityState = document.visibilityState;
|
||||
|
||||
const mockRect = (rect: { left: number; top: number; width: number; height: number }) =>
|
||||
({
|
||||
...rect,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
right: rect.left + rect.width,
|
||||
bottom: rect.top + rect.height,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRectReadOnly;
|
||||
|
||||
describe("AutofillOverlayContentService", () => {
|
||||
let domQueryService: DomQueryService;
|
||||
let domElementVisibilityService: DomElementVisibilityService;
|
||||
@@ -2154,6 +2165,10 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
|
||||
it("calculates the sub frame's offsets if a single frame with the referenced url exists", async () => {
|
||||
const iframe = document.querySelector("iframe") as HTMLIFrameElement;
|
||||
jest
|
||||
.spyOn(iframe, "getBoundingClientRect")
|
||||
.mockReturnValue(mockRect({ left: 0, top: 0, width: 1, height: 1 }));
|
||||
sendMockExtensionMessage(
|
||||
{
|
||||
command: "getSubFrameOffsets",
|
||||
@@ -2270,6 +2285,9 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
document.body.innerHTML = `<iframe id="subframe" src="https://example.com/"></iframe>`;
|
||||
const iframe = document.querySelector("iframe") as HTMLIFrameElement;
|
||||
jest
|
||||
.spyOn(iframe, "getBoundingClientRect")
|
||||
.mockReturnValue(mockRect({ width: 1, height: 1, left: 2, top: 2 }));
|
||||
const subFrameData = {
|
||||
url: "https://example.com/",
|
||||
frameId: 10,
|
||||
@@ -2305,6 +2323,9 @@ describe("AutofillOverlayContentService", () => {
|
||||
it("posts the calculated sub frame data to the background", async () => {
|
||||
document.body.innerHTML = `<iframe id="subframe" src="https://example.com/"></iframe>`;
|
||||
const iframe = document.querySelector("iframe") as HTMLIFrameElement;
|
||||
jest
|
||||
.spyOn(iframe, "getBoundingClientRect")
|
||||
.mockReturnValue(mockRect({ width: 1, height: 1, left: 2, top: 2 }));
|
||||
const subFrameData = {
|
||||
url: "https://example.com/",
|
||||
frameId: 10,
|
||||
@@ -2335,6 +2356,39 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateSubFrameOffsets", () => {
|
||||
it("returns null when iframe has zero width and height", () => {
|
||||
const iframe = document.querySelector("iframe") as HTMLIFrameElement;
|
||||
|
||||
jest
|
||||
.spyOn(iframe, "getBoundingClientRect")
|
||||
.mockReturnValue(mockRect({ left: 0, top: 0, width: 0, height: 0 }));
|
||||
|
||||
const result = autofillOverlayContentService["calculateSubFrameOffsets"](
|
||||
iframe,
|
||||
"https://example.com/",
|
||||
10,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when iframe is not connected to the document", () => {
|
||||
const iframe = document.createElement("iframe") as HTMLIFrameElement;
|
||||
|
||||
jest
|
||||
.spyOn(iframe, "getBoundingClientRect")
|
||||
.mockReturnValue(mockRect({ width: 100, height: 50, left: 10, top: 20 }));
|
||||
|
||||
const result = autofillOverlayContentService["calculateSubFrameOffsets"](
|
||||
iframe,
|
||||
"https://example.com/",
|
||||
10,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkMostRecentlyFocusedFieldHasValue message handler", () => {
|
||||
it("returns true if the most recently focused field has a truthy value", async () => {
|
||||
autofillOverlayContentService["mostRecentlyFocusedField"] = mock<
|
||||
|
||||
@@ -1485,12 +1485,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
frameId?: number,
|
||||
): SubFrameOffsetData {
|
||||
const iframeRect = iframeElement.getBoundingClientRect();
|
||||
const iframeRectHasSize = iframeRect.width > 0 && iframeRect.height > 0;
|
||||
const iframeStyles = globalThis.getComputedStyle(iframeElement);
|
||||
const paddingLeft = parseInt(iframeStyles.getPropertyValue("padding-left")) || 0;
|
||||
const paddingTop = parseInt(iframeStyles.getPropertyValue("padding-top")) || 0;
|
||||
const borderWidthLeft = parseInt(iframeStyles.getPropertyValue("border-left-width")) || 0;
|
||||
const borderWidthTop = parseInt(iframeStyles.getPropertyValue("border-top-width")) || 0;
|
||||
|
||||
if (!iframeRect || !iframeRectHasSize || !iframeElement.isConnected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
url: subFrameUrl,
|
||||
frameId,
|
||||
@@ -1525,6 +1530,10 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
subFrameData.frameId,
|
||||
);
|
||||
|
||||
if (!subFrameOffsets) {
|
||||
return;
|
||||
}
|
||||
|
||||
subFrameData.top += subFrameOffsets.top;
|
||||
subFrameData.left += subFrameOffsets.left;
|
||||
|
||||
@@ -1657,10 +1666,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
globalThis.addEventListener(EVENTS.RESIZE, repositionHandler);
|
||||
}
|
||||
|
||||
private shouldRepositionSubFrameInlineMenuOnScroll = async () => {
|
||||
return await this.sendExtensionMessage("shouldRepositionSubFrameInlineMenuOnScroll");
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes the listeners that facilitate repositioning
|
||||
* the overlay elements on scroll or resize.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
$dark-icon-themes: "theme_dark";
|
||||
|
||||
$font-family-sans-serif: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
$font-family-sans-serif: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
$font-family-source-code-pro: "Source Code Pro", monospace;
|
||||
$font-size-base: 14px;
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<li *ngFor="let button of navButtons" class="tw-flex-1 tw-list-none tw-relative">
|
||||
<button
|
||||
class="tw-w-full tw-flex tw-flex-col tw-items-center tw-px-0.5 tw-py-2 bit-compact:tw-py-1 tw-bg-transparent tw-no-underline hover:tw-no-underline hover:tw-text-primary-600 tw-group/tab-nav-btn hover:tw-bg-hover-default tw-border-2 tw-border-solid tw-border-transparent focus-visible:tw-rounded-lg focus-visible:tw-border-primary-600"
|
||||
[ngClass]="rla.isActive ? 'tw-font-bold tw-text-primary-600' : 'tw-text-muted'"
|
||||
[ngClass]="rla.isActive ? 'tw-font-medium tw-text-primary-600' : 'tw-text-muted'"
|
||||
title="{{ button.label | i18n }}"
|
||||
[routerLink]="button.page"
|
||||
[appA11yTitle]="buttonTitle(button)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100% 100%">
|
||||
<text fill="%23333333" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
<text fill="%23333333" x="50%" y="50%" font-family="\'Inter\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
font-size="18" text-anchor="middle">
|
||||
Loading...
|
||||
</text>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
$dark-icon-themes: "theme_dark";
|
||||
|
||||
$font-family-sans-serif: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
$font-family-sans-serif: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
$font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
$font-size-base: 16px;
|
||||
$font-size-large: 18px;
|
||||
|
||||
@@ -155,11 +155,12 @@ describe("OpenAttachmentsComponent", () => {
|
||||
});
|
||||
|
||||
it("routes the user to the premium page when they cannot access premium features", async () => {
|
||||
const premiumUpgradeService = TestBed.inject(PremiumUpgradePromptService);
|
||||
hasPremiumFromAnySource$.next(false);
|
||||
|
||||
await component.openAttachments();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(["/premium"]);
|
||||
expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables attachments when the edit form is disabled", () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { BadgeModule, ItemModule, ToastService, TypographyModule } from "@bitwarden/components";
|
||||
import { CipherFormContainer } from "@bitwarden/vault";
|
||||
|
||||
@@ -67,6 +68,7 @@ export class OpenAttachmentsComponent implements OnInit {
|
||||
private filePopoutUtilsService: FilePopoutUtilsService,
|
||||
private accountService: AccountService,
|
||||
private cipherFormContainer: CipherFormContainer,
|
||||
private premiumUpgradeService: PremiumUpgradePromptService,
|
||||
) {
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
@@ -115,7 +117,7 @@ export class OpenAttachmentsComponent implements OnInit {
|
||||
/** Routes the user to the attachments screen, if available */
|
||||
async openAttachments() {
|
||||
if (!this.canAccessAttachments) {
|
||||
await this.router.navigate(["/premium"]);
|
||||
await this.premiumUpgradeService.promptForPremium();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,25 +2,69 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { BrowserPremiumUpgradePromptService } from "./browser-premium-upgrade-prompt.service";
|
||||
|
||||
describe("BrowserPremiumUpgradePromptService", () => {
|
||||
let service: BrowserPremiumUpgradePromptService;
|
||||
let router: MockProxy<Router>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
router = mock<Router>();
|
||||
configService = mock<ConfigService>();
|
||||
dialogService = mock<DialogService>();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [BrowserPremiumUpgradePromptService, { provide: Router, useValue: router }],
|
||||
providers: [
|
||||
BrowserPremiumUpgradePromptService,
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
{ provide: DialogService, useValue: dialogService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
service = TestBed.inject(BrowserPremiumUpgradePromptService);
|
||||
});
|
||||
|
||||
describe("promptForPremium", () => {
|
||||
it("navigates to the premium update screen", async () => {
|
||||
let openSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("opens the new premium upgrade dialog when feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
|
||||
);
|
||||
expect(openSpy).toHaveBeenCalledWith(dialogService);
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("navigates to the premium update screen when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith(["/premium"]);
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
/**
|
||||
* This class handles the premium upgrade process for the browser extension.
|
||||
*/
|
||||
export class BrowserPremiumUpgradePromptService implements PremiumUpgradePromptService {
|
||||
private router = inject(Router);
|
||||
private configService = inject(ConfigService);
|
||||
private dialogService = inject(DialogService);
|
||||
|
||||
async promptForPremium() {
|
||||
/**
|
||||
* Navigate to the premium update screen.
|
||||
*/
|
||||
await this.router.navigate(["/premium"]);
|
||||
const showNewDialog = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
|
||||
);
|
||||
|
||||
if (showNewDialog) {
|
||||
PremiumUpgradeDialogComponent.open(this.dialogService);
|
||||
} else {
|
||||
/**
|
||||
* Navigate to the premium update screen.
|
||||
*/
|
||||
await this.router.navigate(["/premium"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ config.content = [
|
||||
"../../libs/vault/src/**/*.{html,ts}",
|
||||
"../../libs/angular/src/**/*.{html,ts}",
|
||||
"../../libs/vault/src/**/*.{html,ts}",
|
||||
"../../libs/pricing/src/**/*.{html,ts}",
|
||||
];
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -132,7 +132,7 @@ export class AvatarComponent implements OnChanges, OnInit {
|
||||
textTag.setAttribute("fill", Utils.pickTextColorBasedOnBgColor(color, 135, true));
|
||||
textTag.setAttribute(
|
||||
"font-family",
|
||||
'Roboto,"Helvetica Neue",Helvetica,Arial,' +
|
||||
'Inter,"Helvetica Neue",Helvetica,Arial,' +
|
||||
'sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"',
|
||||
);
|
||||
textTag.textContent = character;
|
||||
|
||||
@@ -19,14 +19,23 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { CalloutModule, DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-send-add-edit",
|
||||
templateUrl: "add-edit.component.html",
|
||||
imports: [CommonModule, JslibModule, ReactiveFormsModule, CalloutModule],
|
||||
providers: [
|
||||
{
|
||||
provide: PremiumUpgradePromptService,
|
||||
useClass: DesktopPremiumUpgradePromptService,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AddEditComponent extends BaseAddEditComponent {
|
||||
constructor(
|
||||
@@ -45,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent {
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
accountService: AccountService,
|
||||
toastService: ToastService,
|
||||
premiumUpgradePromptService: PremiumUpgradePromptService,
|
||||
) {
|
||||
super(
|
||||
i18nService,
|
||||
@@ -62,6 +72,7 @@ export class AddEditComponent extends BaseAddEditComponent {
|
||||
billingAccountProfileStateService,
|
||||
accountService,
|
||||
toastService,
|
||||
premiumUpgradePromptService,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100% 100%">
|
||||
<text fill="%23333333" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
<text fill="%23333333" x="50%" y="50%" font-family="\'Inter\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
font-size="18" text-anchor="middle">
|
||||
Loading...
|
||||
</text>
|
||||
|
||||
@@ -4193,5 +4193,29 @@
|
||||
},
|
||||
"cardNumberLabel": {
|
||||
"message": "Card number"
|
||||
},
|
||||
"upgradeNow": {
|
||||
"message": "Upgrade now"
|
||||
},
|
||||
"builtInAuthenticator": {
|
||||
"message": "Built-in authenticator"
|
||||
},
|
||||
"secureFileStorage": {
|
||||
"message": "Secure file storage"
|
||||
},
|
||||
"emergencyAccess": {
|
||||
"message": "Emergency access"
|
||||
},
|
||||
"breachMonitoring": {
|
||||
"message": "Breach monitoring"
|
||||
},
|
||||
"andMoreFeatures": {
|
||||
"message": "And more!"
|
||||
},
|
||||
"planDescPremium": {
|
||||
"message": "Complete online security"
|
||||
},
|
||||
"upgradeToPremium": {
|
||||
"message": "Upgrade to Premium"
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
&.active {
|
||||
.filter-button {
|
||||
font-weight: bold;
|
||||
font-weight: 500;
|
||||
@include themify($themes) {
|
||||
color: themed("primaryColor");
|
||||
}
|
||||
@@ -114,7 +114,7 @@
|
||||
.filter-button {
|
||||
@include themify($themes) {
|
||||
color: themed("primaryColor");
|
||||
font-weight: bold;
|
||||
font-weight: 500;
|
||||
}
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
$dark-icon-themes: "theme_dark";
|
||||
|
||||
$font-family-sans-serif: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
$font-family-sans-serif: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
$font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
$font-size-base: 14px;
|
||||
$font-size-large: 18px;
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { DesktopPremiumUpgradePromptService } from "./desktop-premium-upgrade-prompt.service";
|
||||
|
||||
describe("DesktopPremiumUpgradePromptService", () => {
|
||||
let service: DesktopPremiumUpgradePromptService;
|
||||
let messager: MockProxy<MessagingService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
messager = mock<MessagingService>();
|
||||
configService = mock<ConfigService>();
|
||||
dialogService = mock<DialogService>();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DesktopPremiumUpgradePromptService,
|
||||
{ provide: MessagingService, useValue: messager },
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
{ provide: DialogService, useValue: dialogService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -22,9 +33,38 @@ describe("DesktopPremiumUpgradePromptService", () => {
|
||||
});
|
||||
|
||||
describe("promptForPremium", () => {
|
||||
it("navigates to the premium update screen", async () => {
|
||||
let openSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("opens the new premium upgrade dialog when feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
|
||||
);
|
||||
expect(openSpy).toHaveBeenCalledWith(dialogService);
|
||||
expect(messager.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends openPremium message when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
|
||||
);
|
||||
expect(messager.send).toHaveBeenCalledWith("openPremium");
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { inject } from "@angular/core";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
/**
|
||||
* This class handles the premium upgrade process for the desktop.
|
||||
*/
|
||||
export class DesktopPremiumUpgradePromptService implements PremiumUpgradePromptService {
|
||||
private messagingService = inject(MessagingService);
|
||||
private configService = inject(ConfigService);
|
||||
private dialogService = inject(DialogService);
|
||||
|
||||
async promptForPremium() {
|
||||
this.messagingService.send("openPremium");
|
||||
const showNewDialog = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
|
||||
);
|
||||
|
||||
if (showNewDialog) {
|
||||
PremiumUpgradeDialogComponent.open(this.dialogService);
|
||||
} else {
|
||||
this.messagingService.send("openPremium");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ config.content = [
|
||||
"../../libs/key-management-ui/src/**/*.{html,ts}",
|
||||
"../../libs/angular/src/**/*.{html,ts}",
|
||||
"../../libs/vault/src/**/*.{html,ts,mdx}",
|
||||
"../../libs/pricing/src/**/*.{html,ts}",
|
||||
];
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -147,18 +147,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "premiumRequired": {
|
||||
const premiumConfirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "premiumRequired" },
|
||||
content: { key: "premiumRequiredDesc" },
|
||||
acceptButtonText: { key: "upgrade" },
|
||||
type: "success",
|
||||
});
|
||||
if (premiumConfirmed) {
|
||||
await this.router.navigate(["settings/subscription/premium"]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "emailVerificationRequired": {
|
||||
const emailVerificationConfirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "emailVerificationRequired" },
|
||||
|
||||
@@ -113,14 +113,37 @@ export class RecoverTwoFactorComponent implements OnInit {
|
||||
await this.router.navigate(["/settings/security/two-factor"]);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ErrorResponse) {
|
||||
this.logService.error("Error logging in automatically: ", error.message);
|
||||
|
||||
if (error.message.includes("Two-step token is invalid")) {
|
||||
this.formGroup.get("recoveryCode")?.setErrors({
|
||||
invalidRecoveryCode: { message: this.i18nService.t("invalidRecoveryCode") },
|
||||
if (
|
||||
error.message.includes(
|
||||
"Two-factor recovery has been performed. SSO authentication is required.",
|
||||
)
|
||||
) {
|
||||
// [PM-21153]: Organization users with as SSO requirement need to be able to recover 2FA,
|
||||
// but still be bound by the SSO requirement to log in. Therefore, we show a success toast for recovering 2FA,
|
||||
// but then inform them that they need to log in via SSO and redirect them to the login page.
|
||||
// The response tested here is a specific message for this scenario from request validation.
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("twoStepRecoverDisabled"),
|
||||
});
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("ssoLoginIsRequired"),
|
||||
});
|
||||
|
||||
await this.router.navigate(["/login"]);
|
||||
} else {
|
||||
this.validationService.showError(error.message);
|
||||
this.logService.error("Error logging in automatically: ", error.message);
|
||||
|
||||
if (error.message.includes("Two-step token is invalid")) {
|
||||
this.formGroup.get("recoveryCode")?.setErrors({
|
||||
invalidRecoveryCode: { message: this.i18nService.t("invalidRecoveryCode") },
|
||||
});
|
||||
} else {
|
||||
this.validationService.showError(error.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logService.error("Error logging in automatically: ", error);
|
||||
|
||||
@@ -96,15 +96,6 @@ export class EmergencyAccessComponent implements OnInit {
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
async premiumRequired() {
|
||||
const canAccessPremium = await firstValueFrom(this.canAccessPremium$);
|
||||
|
||||
if (!canAccessPremium) {
|
||||
this.messagingService.send("premiumRequired");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
edit = async (details: GranteeEmergencyAccess) => {
|
||||
const canAccessPremium = await firstValueFrom(this.canAccessPremium$);
|
||||
const dialogRef = EmergencyAccessAddEditComponent.open(this.dialogService, {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import {
|
||||
first,
|
||||
firstValueFrom,
|
||||
lastValueFrom,
|
||||
Observable,
|
||||
Subject,
|
||||
@@ -264,13 +263,6 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async premiumRequired() {
|
||||
if (!(await firstValueFrom(this.canAccessPremium$))) {
|
||||
this.messagingService.send("premiumRequired");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
protected getTwoFactorProviders() {
|
||||
return this.twoFactorApiService.getTwoFactorProviders();
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { inject } from "@angular/core";
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
RouterStateSnapshot,
|
||||
Router,
|
||||
CanActivateFn,
|
||||
Router,
|
||||
RouterStateSnapshot,
|
||||
UrlTree,
|
||||
} from "@angular/router";
|
||||
import { Observable, of } from "rxjs";
|
||||
import { from, Observable, of } from "rxjs";
|
||||
import { switchMap, tap } from "rxjs/operators";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
|
||||
/**
|
||||
* CanActivate guard that checks if the user has premium and otherwise triggers the "premiumRequired"
|
||||
* message and blocks navigation.
|
||||
* CanActivate guard that checks if the user has premium and otherwise triggers the premium upgrade
|
||||
* flow and blocks navigation.
|
||||
*/
|
||||
export function hasPremiumGuard(): CanActivateFn {
|
||||
return (
|
||||
@@ -23,7 +23,7 @@ export function hasPremiumGuard(): CanActivateFn {
|
||||
_state: RouterStateSnapshot,
|
||||
): Observable<boolean | UrlTree> => {
|
||||
const router = inject(Router);
|
||||
const messagingService = inject(MessagingService);
|
||||
const premiumUpgradePromptService = inject(PremiumUpgradePromptService);
|
||||
const billingAccountProfileStateService = inject(BillingAccountProfileStateService);
|
||||
const accountService = inject(AccountService);
|
||||
|
||||
@@ -33,10 +33,14 @@ export function hasPremiumGuard(): CanActivateFn {
|
||||
? billingAccountProfileStateService.hasPremiumFromAnySource$(account.id)
|
||||
: of(false),
|
||||
),
|
||||
tap((userHasPremium: boolean) => {
|
||||
switchMap((userHasPremium: boolean) => {
|
||||
// Can't call async method inside observables so instead, wait for service then switch back to the boolean
|
||||
if (!userHasPremium) {
|
||||
messagingService.send("premiumRequired");
|
||||
return from(premiumUpgradePromptService.promptForPremium()).pipe(
|
||||
switchMap(() => of(userHasPremium)),
|
||||
);
|
||||
}
|
||||
return of(userHasPremium);
|
||||
}),
|
||||
// Prevent trapping the user on the login page, since that's an awful UX flow
|
||||
tap((userHasPremium: boolean) => {
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import {
|
||||
@@ -28,12 +33,7 @@ import {
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { SubscriptionPricingService } from "../../services/subscription-pricing.service";
|
||||
import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "../../types/subscription-pricing-tier";
|
||||
import {
|
||||
UnifiedUpgradeDialogComponent,
|
||||
UnifiedUpgradeDialogParams,
|
||||
@@ -91,7 +91,7 @@ export class PremiumVNextComponent {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private syncService: SyncService,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private subscriptionPricingService: SubscriptionPricingService,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
filter,
|
||||
@@ -12,10 +13,9 @@ import {
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
catchError,
|
||||
shareReplay,
|
||||
} from "rxjs";
|
||||
import { debounceTime } from "rxjs/operators";
|
||||
|
||||
@@ -23,6 +23,8 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service";
|
||||
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -35,12 +37,10 @@ import {
|
||||
getBillingAddressFromForm,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||
import {
|
||||
tokenizablePaymentMethodToLegacyEnum,
|
||||
NonTokenizablePaymentMethods,
|
||||
tokenizablePaymentMethodToLegacyEnum,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
import { SubscriptionPricingService } from "@bitwarden/web-vault/app/billing/services/subscription-pricing.service";
|
||||
import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types";
|
||||
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@@ -137,7 +137,7 @@ export class PremiumComponent {
|
||||
private accountService: AccountService,
|
||||
private subscriberBillingClient: SubscriberBillingClient,
|
||||
private taxClient: TaxClient,
|
||||
private subscriptionPricingService: SubscriptionPricingService,
|
||||
private subscriptionPricingService: DefaultSubscriptionPricingService,
|
||||
) {
|
||||
this.isSelfHost = this.platformUtilsService.isSelfHost();
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "../../../types/subscription-pricing-tier";
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
UpgradeAccountComponent,
|
||||
UpgradeAccountStatus,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Component, Inject, OnInit, signal } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { PersonalSubscriptionPricingTierId } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import {
|
||||
ButtonModule,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
|
||||
import { AccountBillingClient, TaxClient } from "../../../clients";
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
import { PersonalSubscriptionPricingTierId } from "../../../types/subscription-pricing-tier";
|
||||
import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component";
|
||||
import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service";
|
||||
import {
|
||||
|
||||
@@ -4,15 +4,15 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "../../../types/subscription-pricing-tier";
|
||||
|
||||
import { UpgradeAccountComponent, UpgradeAccountStatus } from "./upgrade-account.component";
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("UpgradeAccountComponent", () => {
|
||||
let sut: UpgradeAccountComponent;
|
||||
let fixture: ComponentFixture<UpgradeAccountComponent>;
|
||||
const mockI18nService = mock<I18nService>();
|
||||
const mockSubscriptionPricingService = mock<SubscriptionPricingService>();
|
||||
const mockSubscriptionPricingService = mock<SubscriptionPricingServiceAbstraction>();
|
||||
|
||||
// Mock pricing tiers data
|
||||
const mockPricingTiers: PersonalSubscriptionPricingTier[] = [
|
||||
@@ -57,7 +57,10 @@ describe("UpgradeAccountComponent", () => {
|
||||
imports: [NoopAnimationsModule, UpgradeAccountComponent, PricingCardComponent, CdkTrapFocus],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService },
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: mockSubscriptionPricingService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(UpgradeAccountComponent, {
|
||||
@@ -170,7 +173,10 @@ describe("UpgradeAccountComponent", () => {
|
||||
],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService },
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: mockSubscriptionPricingService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(UpgradeAccountComponent, {
|
||||
|
||||
@@ -2,22 +2,23 @@ import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { catchError, of } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import { ButtonType, DialogModule } from "@bitwarden/components";
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
|
||||
import { SharedModule } from "../../../../shared";
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "../../../types/subscription-pricing-tier";
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import { ButtonType, DialogModule, ToastService } from "@bitwarden/components";
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
|
||||
import { SharedModule } from "../../../../shared";
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
|
||||
export const UpgradeAccountStatus = {
|
||||
Closed: "closed",
|
||||
@@ -72,14 +73,26 @@ export class UpgradeAccountComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private subscriptionPricingService: SubscriptionPricingService,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
private toastService: ToastService,
|
||||
private destroyRef: DestroyRef,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subscriptionPricingService
|
||||
.getPersonalSubscriptionPricingTiers$()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("unexpectedError"),
|
||||
});
|
||||
this.loading.set(false);
|
||||
return of([]);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((plans) => {
|
||||
this.setupCardDetails(plans);
|
||||
this.loading.set(false);
|
||||
|
||||
@@ -119,14 +119,13 @@ describe("UpgradeNavButtonComponent", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should refresh token and sync after upgrading to premium", async () => {
|
||||
it("should full sync after upgrading to premium", async () => {
|
||||
const mockDialogRef = mock<DialogRef<UnifiedUpgradeDialogResult>>();
|
||||
mockDialogRef.closed = of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium });
|
||||
mockDialogService.open.mockReturnValue(mockDialogRef);
|
||||
|
||||
await component.upgrade();
|
||||
|
||||
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
|
||||
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ export class UpgradeNavButtonComponent {
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium) {
|
||||
await this.apiService.refreshIdentityToken();
|
||||
await this.syncService.fullSync(true);
|
||||
} else if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies) {
|
||||
const redirectUrl = `/organizations/${result.organizationId}/vault`;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OrganizationResponse } from "@bitwarden/common/admin-console/models/res
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
NonTokenizedPaymentMethod,
|
||||
TokenizedPaymentMethod,
|
||||
} from "../../../../payment/types";
|
||||
import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier";
|
||||
|
||||
import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
SubscriptionInformation,
|
||||
} from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
@@ -30,11 +35,6 @@ import {
|
||||
TokenizedPaymentMethod,
|
||||
} from "../../../../payment/types";
|
||||
import { mapAccountToSubscriber } from "../../../../types";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "../../../../types/subscription-pricing-tier";
|
||||
|
||||
export type PlanDetails = {
|
||||
tier: PersonalSubscriptionPricingTierId;
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
} from "rxjs";
|
||||
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components";
|
||||
@@ -43,13 +49,7 @@ import {
|
||||
TokenizedPaymentMethod,
|
||||
} from "../../../payment/types";
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
|
||||
import { BitwardenSubscriber } from "../../../types";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "../../../types/subscription-pricing-tier";
|
||||
|
||||
import {
|
||||
PaymentFormValues,
|
||||
@@ -128,7 +128,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private subscriptionPricingService: SubscriptionPricingService,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
private toastService: ToastService,
|
||||
private logService: LogService,
|
||||
private destroyRef: DestroyRef,
|
||||
@@ -145,29 +145,42 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
this.pricingTiers$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
|
||||
this.pricingTiers$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((plans) => {
|
||||
const planDetails = plans.find((plan) => plan.id === this.selectedPlanId());
|
||||
this.pricingTiers$
|
||||
.pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("error"),
|
||||
message: this.i18nService.t("unexpectedError"),
|
||||
});
|
||||
this.loading.set(false);
|
||||
return of([]);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((plans) => {
|
||||
const planDetails = plans.find((plan) => plan.id === this.selectedPlanId());
|
||||
|
||||
if (planDetails) {
|
||||
this.selectedPlan = {
|
||||
tier: this.selectedPlanId(),
|
||||
details: planDetails,
|
||||
};
|
||||
this.passwordManager = {
|
||||
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
|
||||
cost: this.selectedPlan.details.passwordManager.annualPrice,
|
||||
quantity: 1,
|
||||
cadence: "year",
|
||||
};
|
||||
if (planDetails) {
|
||||
this.selectedPlan = {
|
||||
tier: this.selectedPlanId(),
|
||||
details: planDetails,
|
||||
};
|
||||
this.passwordManager = {
|
||||
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
|
||||
cost: this.selectedPlan.details.passwordManager.annualPrice,
|
||||
quantity: 1,
|
||||
cadence: "year",
|
||||
};
|
||||
|
||||
this.upgradeToMessage = this.i18nService.t(
|
||||
this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium",
|
||||
);
|
||||
} else {
|
||||
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
|
||||
return;
|
||||
}
|
||||
});
|
||||
this.upgradeToMessage = this.i18nService.t(
|
||||
this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium",
|
||||
);
|
||||
} else {
|
||||
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe(
|
||||
startWith(this.formGroup.controls.billingAddress.value),
|
||||
|
||||
@@ -795,7 +795,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
: this.i18nService.t("organizationUpgraded"),
|
||||
});
|
||||
|
||||
await this.apiService.refreshIdentityToken();
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
if (!this.acceptingSponsorship && !this.isInTrialFlow) {
|
||||
|
||||
@@ -675,7 +675,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
await this.apiService.refreshIdentityToken();
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
if (!this.acceptingSponsorship && !this.isInTrialFlow) {
|
||||
|
||||
@@ -226,7 +226,7 @@ export class StripeService {
|
||||
base: {
|
||||
color: null,
|
||||
fontFamily:
|
||||
'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif, ' +
|
||||
'Inter, "Helvetica Neue", Helvetica, Arial, sans-serif, ' +
|
||||
'"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
|
||||
fontSize: "16px",
|
||||
fontSmoothing: "antialiased",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div bitTypography="body2">
|
||||
{{ "accessing" | i18n }}:
|
||||
<a [routerLink]="[]" [bitMenuTriggerFor]="environmentOptions">
|
||||
<b class="tw-text-primary-600 tw-font-semibold">{{ currentRegion?.domain }}</b>
|
||||
<b class="tw-text-primary-600 tw-font-medium">{{ currentRegion?.domain }}</b>
|
||||
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
@@ -94,6 +95,7 @@ import { NoopSdkLoadService } from "@bitwarden/common/platform/services/sdk/noop
|
||||
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
||||
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
|
||||
import {
|
||||
DefaultThemeStateService,
|
||||
ThemeStateService,
|
||||
@@ -408,7 +410,16 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: PremiumUpgradePromptService,
|
||||
useClass: WebVaultPremiumUpgradePromptService,
|
||||
deps: [DialogService, Router],
|
||||
deps: [
|
||||
DialogService,
|
||||
ConfigService,
|
||||
AccountService,
|
||||
ApiService,
|
||||
SyncService,
|
||||
BillingAccountProfileStateService,
|
||||
PlatformUtilsService,
|
||||
Router,
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -15,14 +15,12 @@
|
||||
<h3 class="tw-mb-4 tw-text-xl tw-font-bold">{{ title }}</h3>
|
||||
<p class="tw-mb-0">{{ description }}</p>
|
||||
</bit-card-content>
|
||||
<span
|
||||
bitBadge
|
||||
[variant]="requiresPremium ? 'success' : 'primary'"
|
||||
class="tw-absolute tw-left-2 tw-top-2 tw-leading-none"
|
||||
*ngIf="disabled"
|
||||
>
|
||||
<ng-container *ngIf="requiresPremium">{{ "premium" | i18n }}</ng-container>
|
||||
<ng-container *ngIf="!requiresPremium">{{ "upgrade" | i18n }}</ng-container>
|
||||
</span>
|
||||
@if (requiresPremium) {
|
||||
<app-premium-badge class="tw-absolute tw-left-2 tw-top-2"></app-premium-badge>
|
||||
} @else if (requiresUpgrade) {
|
||||
<span bitBadge variant="primary" class="tw-absolute tw-left-2 tw-top-2">
|
||||
{{ "upgrade" | i18n }}
|
||||
</span>
|
||||
}
|
||||
</bit-base-card>
|
||||
</a>
|
||||
|
||||
@@ -37,4 +37,8 @@ export class ReportCardComponent {
|
||||
protected get requiresPremium() {
|
||||
return this.variant == ReportVariant.RequiresPremium;
|
||||
}
|
||||
|
||||
protected get requiresUpgrade() {
|
||||
return this.variant == ReportVariant.RequiresUpgrade;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { importProvidersFrom } from "@angular/core";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
|
||||
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import {
|
||||
BadgeModule,
|
||||
BaseCardComponent,
|
||||
IconModule,
|
||||
CardContentComponent,
|
||||
I18nMockService,
|
||||
IconModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { PreloadedEnglishI18nModule } from "../../../../core/tests";
|
||||
@@ -30,6 +36,37 @@ export default {
|
||||
PremiumBadgeComponent,
|
||||
BaseCardComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: {
|
||||
activeAccount$: of({
|
||||
id: "123",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
premium: "Premium",
|
||||
upgrade: "Upgrade",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: {
|
||||
hasPremiumFromAnySource$: () => of(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PremiumUpgradePromptService,
|
||||
useValue: {
|
||||
promptForPremium: (orgId?: string) => {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { importProvidersFrom } from "@angular/core";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
|
||||
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import {
|
||||
BadgeModule,
|
||||
BaseCardComponent,
|
||||
@@ -33,6 +37,28 @@ export default {
|
||||
BaseCardComponent,
|
||||
],
|
||||
declarations: [ReportCardComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: {
|
||||
activeAccount$: of({
|
||||
id: "123",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: {
|
||||
hasPremiumFromAnySource$: () => of(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PremiumUpgradePromptService,
|
||||
useValue: {
|
||||
promptForPremium: (orgId?: string) => {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
|
||||
import { BaseCardComponent, CardContentComponent } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared/shared.module";
|
||||
@@ -9,7 +10,13 @@ import { ReportCardComponent } from "./report-card/report-card.component";
|
||||
import { ReportListComponent } from "./report-list/report-list.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, SharedModule, BaseCardComponent, CardContentComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
BaseCardComponent,
|
||||
CardContentComponent,
|
||||
PremiumBadgeComponent,
|
||||
],
|
||||
declarations: [ReportCardComponent, ReportListComponent],
|
||||
exports: [ReportCardComponent, ReportListComponent],
|
||||
})
|
||||
|
||||
@@ -9,6 +9,9 @@ export class MasterPasswordUnlockDataRequest {
|
||||
email: string;
|
||||
masterKeyAuthenticationHash: string;
|
||||
|
||||
/**
|
||||
* Also known as masterKeyWrappedUserKey in other parts of the codebase
|
||||
*/
|
||||
masterKeyEncryptedUserKey: string;
|
||||
|
||||
masterPasswordHint?: string;
|
||||
@@ -17,7 +20,7 @@ export class MasterPasswordUnlockDataRequest {
|
||||
kdfConfig: KdfConfig,
|
||||
email: string,
|
||||
masterKeyAuthenticationHash: string,
|
||||
masterKeyEncryptedUserKey: string,
|
||||
masterKeyWrappedUserKey: string,
|
||||
masterPasswordHash?: string,
|
||||
) {
|
||||
this.kdfType = kdfConfig.kdfType;
|
||||
@@ -29,7 +32,7 @@ export class MasterPasswordUnlockDataRequest {
|
||||
|
||||
this.email = email;
|
||||
this.masterKeyAuthenticationHash = masterKeyAuthenticationHash;
|
||||
this.masterKeyEncryptedUserKey = masterKeyEncryptedUserKey;
|
||||
this.masterKeyEncryptedUserKey = masterKeyWrappedUserKey;
|
||||
this.masterPasswordHint = masterPasswordHash;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<h1
|
||||
bitTypography="h1"
|
||||
noMargin
|
||||
class="tw-m-0 tw-mr-2 tw-leading-10 tw-flex tw-gap-1"
|
||||
class="tw-m-0 tw-mr-2 tw-leading-10 tw-flex tw-gap-1 tw-font-medium"
|
||||
[title]="title || (routeData.titleId | i18n)"
|
||||
>
|
||||
<div class="tw-truncate">
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
[href]="more.marketingRoute.route"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-bold !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
|
||||
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
|
||||
>
|
||||
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
|
||||
<div>
|
||||
@@ -47,7 +47,7 @@
|
||||
*ngIf="!more.marketingRoute.external"
|
||||
[routerLink]="more.marketingRoute.route"
|
||||
rel="noreferrer"
|
||||
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-bold !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
|
||||
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
|
||||
>
|
||||
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
|
||||
<div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
[routerLink]="product.appRoute"
|
||||
[ngClass]="
|
||||
product.isActive
|
||||
? 'tw-bg-primary-600 tw-font-bold !tw-text-contrast tw-ring-offset-2 hover:tw-bg-primary-600'
|
||||
? 'tw-bg-primary-600 tw-font-medium !tw-text-contrast tw-ring-offset-2 hover:tw-bg-primary-600'
|
||||
: ''
|
||||
"
|
||||
class="tw-group/product-link tw-flex tw-h-24 tw-w-28 tw-flex-col tw-items-center tw-justify-center tw-rounded tw-p-1 tw-text-primary-600 tw-outline-none hover:tw-bg-background-alt hover:tw-text-primary-700 hover:tw-no-underline focus-visible:!tw-ring-2 focus-visible:!tw-ring-primary-700"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6,11 +6,14 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DialogRef, DIALOG_DATA, DialogService, ToastService } from "@bitwarden/components";
|
||||
@@ -73,6 +76,7 @@ describe("VaultItemDialogComponent", () => {
|
||||
{ provide: LogService, useValue: {} },
|
||||
{ provide: CipherService, useValue: {} },
|
||||
{ provide: AccountService, useValue: { activeAccount$: { pipe: () => ({}) } } },
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false) } },
|
||||
{ provide: Router, useValue: {} },
|
||||
{ provide: ActivatedRoute, useValue: {} },
|
||||
{
|
||||
@@ -84,6 +88,8 @@ describe("VaultItemDialogComponent", () => {
|
||||
{ provide: ApiService, useValue: {} },
|
||||
{ provide: EventCollectionService, useValue: {} },
|
||||
{ provide: RoutedVaultFilterService, useValue: {} },
|
||||
{ provide: SyncService, useValue: {} },
|
||||
{ provide: PlatformUtilsService, useValue: {} },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
@@ -326,6 +327,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
private organizationWarningsService: OrganizationWarningsService,
|
||||
private policyService: PolicyService,
|
||||
private unifiedUpgradePromptService: UnifiedUpgradePromptService,
|
||||
private premiumUpgradePromptService: PremiumUpgradePromptService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -867,7 +869,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
}
|
||||
|
||||
if (cipher.organizationId == null && !this.canAccessPremium) {
|
||||
this.messagingService.send("premiumRequired");
|
||||
await this.premiumUpgradePromptService.promptForPremium();
|
||||
return;
|
||||
} else if (cipher.organizationId != null) {
|
||||
const org = await firstValueFrom(
|
||||
|
||||
@@ -2,8 +2,19 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { lastValueFrom, of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { DialogRef, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
UnifiedUpgradeDialogComponent,
|
||||
UnifiedUpgradeDialogStatus,
|
||||
} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
|
||||
|
||||
import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component";
|
||||
|
||||
@@ -13,13 +24,27 @@ describe("WebVaultPremiumUpgradePromptService", () => {
|
||||
let service: WebVaultPremiumUpgradePromptService;
|
||||
let dialogServiceMock: jest.Mocked<DialogService>;
|
||||
let routerMock: jest.Mocked<Router>;
|
||||
let dialogRefMock: jest.Mocked<DialogRef<VaultItemDialogResult>>;
|
||||
let dialogRefMock: jest.Mocked<DialogRef>;
|
||||
let configServiceMock: jest.Mocked<ConfigService>;
|
||||
let accountServiceMock: jest.Mocked<AccountService>;
|
||||
let apiServiceMock: jest.Mocked<ApiService>;
|
||||
let syncServiceMock: jest.Mocked<SyncService>;
|
||||
let billingAccountProfileServiceMock: jest.Mocked<BillingAccountProfileStateService>;
|
||||
let platformUtilsServiceMock: jest.Mocked<PlatformUtilsService>;
|
||||
|
||||
beforeEach(() => {
|
||||
dialogServiceMock = {
|
||||
openSimpleDialog: jest.fn(),
|
||||
} as unknown as jest.Mocked<DialogService>;
|
||||
|
||||
configServiceMock = {
|
||||
getFeatureFlag: jest.fn().mockReturnValue(false),
|
||||
} as unknown as jest.Mocked<ConfigService>;
|
||||
|
||||
accountServiceMock = {
|
||||
activeAccount$: of({ id: "user-123" }),
|
||||
} as unknown as jest.Mocked<AccountService>;
|
||||
|
||||
routerMock = {
|
||||
navigate: jest.fn(),
|
||||
} as unknown as jest.Mocked<Router>;
|
||||
@@ -28,12 +53,34 @@ describe("WebVaultPremiumUpgradePromptService", () => {
|
||||
close: jest.fn(),
|
||||
} as unknown as jest.Mocked<DialogRef<VaultItemDialogResult>>;
|
||||
|
||||
apiServiceMock = {
|
||||
refreshIdentityToken: jest.fn().mockReturnValue({}),
|
||||
} as unknown as jest.Mocked<ApiService>;
|
||||
|
||||
syncServiceMock = {
|
||||
fullSync: jest.fn(),
|
||||
} as unknown as jest.Mocked<SyncService>;
|
||||
|
||||
billingAccountProfileServiceMock = {
|
||||
hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)),
|
||||
} as unknown as jest.Mocked<BillingAccountProfileStateService>;
|
||||
|
||||
platformUtilsServiceMock = {
|
||||
isSelfHost: jest.fn().mockReturnValue(false),
|
||||
} as unknown as jest.Mocked<PlatformUtilsService>;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WebVaultPremiumUpgradePromptService,
|
||||
{ provide: DialogService, useValue: dialogServiceMock },
|
||||
{ provide: Router, useValue: routerMock },
|
||||
{ provide: DialogRef, useValue: dialogRefMock },
|
||||
{ provide: ConfigService, useValue: configServiceMock },
|
||||
{ provide: AccountService, useValue: accountServiceMock },
|
||||
{ provide: ApiService, useValue: apiServiceMock },
|
||||
{ provide: SyncService, useValue: syncServiceMock },
|
||||
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileServiceMock },
|
||||
{ provide: PlatformUtilsService, useValue: platformUtilsServiceMock },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -84,4 +131,144 @@ describe("WebVaultPremiumUpgradePromptService", () => {
|
||||
expect(routerMock.navigate).not.toHaveBeenCalled();
|
||||
expect(dialogRefMock.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("premium status check", () => {
|
||||
it("should not prompt if user already has premium (feature flag off)", async () => {
|
||||
configServiceMock.getFeatureFlag.mockReturnValue(Promise.resolve(false));
|
||||
billingAccountProfileServiceMock.hasPremiumFromAnySource$.mockReturnValue(of(true));
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(routerMock.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not prompt if user already has premium (feature flag on)", async () => {
|
||||
configServiceMock.getFeatureFlag.mockImplementation((flag: FeatureFlag) => {
|
||||
if (flag === FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
billingAccountProfileServiceMock.hasPremiumFromAnySource$.mockReturnValue(of(true));
|
||||
|
||||
const unifiedDialogRefMock = {
|
||||
closed: of({ status: UnifiedUpgradeDialogStatus.Closed }),
|
||||
close: jest.fn(),
|
||||
} as any;
|
||||
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(UnifiedUpgradeDialogComponent.open).not.toHaveBeenCalled();
|
||||
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(routerMock.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("new premium upgrade dialog with post-upgrade actions", () => {
|
||||
beforeEach(() => {
|
||||
configServiceMock.getFeatureFlag.mockImplementation((flag: FeatureFlag) => {
|
||||
if (flag === FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when self-hosted", () => {
|
||||
beforeEach(() => {
|
||||
platformUtilsServiceMock.isSelfHost.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should navigate to subscription page instead of opening dialog", async () => {
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]);
|
||||
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when not self-hosted", () => {
|
||||
beforeEach(() => {
|
||||
platformUtilsServiceMock.isSelfHost.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("should full sync when user upgrades to premium", async () => {
|
||||
const unifiedDialogRefMock = {
|
||||
closed: of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium }),
|
||||
close: jest.fn(),
|
||||
} as any;
|
||||
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
|
||||
data: {
|
||||
account: { id: "user-123" },
|
||||
planSelectionStepTitleOverride: "upgradeYourPlan",
|
||||
hideContinueWithoutUpgradingButton: true,
|
||||
},
|
||||
});
|
||||
expect(syncServiceMock.fullSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should full sync when user upgrades to families", async () => {
|
||||
const unifiedDialogRefMock = {
|
||||
closed: of({ status: UnifiedUpgradeDialogStatus.UpgradedToFamilies }),
|
||||
close: jest.fn(),
|
||||
} as any;
|
||||
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
|
||||
data: {
|
||||
account: { id: "user-123" },
|
||||
planSelectionStepTitleOverride: "upgradeYourPlan",
|
||||
hideContinueWithoutUpgradingButton: true,
|
||||
},
|
||||
});
|
||||
expect(syncServiceMock.fullSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should not refresh or sync when user closes dialog without upgrading", async () => {
|
||||
const unifiedDialogRefMock = {
|
||||
closed: of({ status: UnifiedUpgradeDialogStatus.Closed }),
|
||||
close: jest.fn(),
|
||||
} as any;
|
||||
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
|
||||
data: {
|
||||
account: { id: "user-123" },
|
||||
planSelectionStepTitleOverride: "upgradeYourPlan",
|
||||
hideContinueWithoutUpgradingButton: true,
|
||||
},
|
||||
});
|
||||
expect(apiServiceMock.refreshIdentityToken).not.toHaveBeenCalled();
|
||||
expect(syncServiceMock.fullSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not open new dialog if organizationId is provided", async () => {
|
||||
const organizationId = "test-org-id" as OrganizationId;
|
||||
dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true)));
|
||||
|
||||
const openSpy = jest.spyOn(UnifiedUpgradeDialogComponent, "open");
|
||||
openSpy.mockClear();
|
||||
|
||||
await service.promptForPremium(organizationId);
|
||||
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "upgradeOrganization" },
|
||||
content: { key: "upgradeOrganizationDesc" },
|
||||
acceptButtonText: { key: "upgradeOrganization" },
|
||||
type: "info",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { Injectable, Optional } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { Subject } from "rxjs";
|
||||
import { firstValueFrom, lastValueFrom, Subject } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { DialogRef, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
UnifiedUpgradeDialogComponent,
|
||||
UnifiedUpgradeDialogStatus,
|
||||
} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
|
||||
|
||||
import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component";
|
||||
|
||||
@@ -15,14 +26,44 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
|
||||
|
||||
constructor(
|
||||
private dialogService: DialogService,
|
||||
private configService: ConfigService,
|
||||
private accountService: AccountService,
|
||||
private apiService: ApiService,
|
||||
private syncService: SyncService,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private router: Router,
|
||||
@Optional() private dialog?: DialogRef<VaultItemDialogResult>,
|
||||
) {}
|
||||
private readonly subscriptionPageRoute = "settings/subscription/premium";
|
||||
|
||||
/**
|
||||
* Prompts the user for a premium upgrade.
|
||||
*/
|
||||
async promptForPremium(organizationId?: OrganizationId) {
|
||||
const account = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
const hasPremium = await firstValueFrom(
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
|
||||
);
|
||||
if (hasPremium) {
|
||||
// Already has premium, don't prompt
|
||||
return;
|
||||
}
|
||||
|
||||
const showNewDialog = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
|
||||
);
|
||||
|
||||
// Per conversation in PM-23713, retain the existing upgrade org flow for now, will be addressed
|
||||
// as a part of https://bitwarden.atlassian.net/browse/PM-25507
|
||||
if (showNewDialog && !organizationId) {
|
||||
await this.promptForPremiumVNext(account);
|
||||
return;
|
||||
}
|
||||
|
||||
let confirmed = false;
|
||||
let route: string[] | null = null;
|
||||
|
||||
@@ -44,7 +85,7 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
|
||||
type: "success",
|
||||
});
|
||||
if (confirmed) {
|
||||
route = ["settings/subscription/premium"];
|
||||
route = [this.subscriptionPageRoute];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +98,31 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
|
||||
this.dialog.close(VaultItemDialogResult.PremiumUpgrade);
|
||||
}
|
||||
}
|
||||
|
||||
private async promptForPremiumVNext(account: Account) {
|
||||
await (this.platformUtilsService.isSelfHost()
|
||||
? this.redirectToSubscriptionPage()
|
||||
: this.openUpgradeDialog(account));
|
||||
}
|
||||
|
||||
private async redirectToSubscriptionPage() {
|
||||
await this.router.navigate([this.subscriptionPageRoute]);
|
||||
}
|
||||
|
||||
private async openUpgradeDialog(account: Account) {
|
||||
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
account,
|
||||
planSelectionStepTitleOverride: "upgradeYourPlan",
|
||||
hideContinueWithoutUpgradingButton: true,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (
|
||||
result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
|
||||
result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
|
||||
) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100% 100%">
|
||||
<text fill="%23FBFBFB" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
<text fill="%23FBFBFB" x="50%" y="50%" font-family="\'Inter\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
font-size="18" text-anchor="middle">
|
||||
Loading...
|
||||
</text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100% 100%">
|
||||
<text fill="%23333333" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
<text fill="%23333333" x="50%" y="50%" font-family="\'Inter\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
font-size="18" text-anchor="middle">
|
||||
Loading...
|
||||
</text>
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
"reviewAtRiskPasswords": {
|
||||
"message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords."
|
||||
},
|
||||
"reviewAtRiskLoginsPrompt": {
|
||||
"message": "Review at-risk logins"
|
||||
},
|
||||
"dataLastUpdated": {
|
||||
"message": "Data last updated: $DATE$",
|
||||
"placeholders": {
|
||||
@@ -166,6 +169,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"criticalApplicationsMarked": {
|
||||
"message": "critical applications marked"
|
||||
},
|
||||
"countOfCriticalApplications": {
|
||||
"message": "$COUNT$ critical applications",
|
||||
"placeholders": {
|
||||
@@ -274,6 +280,15 @@
|
||||
"applicationsMarkedAsCriticalSuccess": {
|
||||
"message": "Applications marked as critical"
|
||||
},
|
||||
"criticalApplicationsMarkedSuccess": {
|
||||
"message": "$COUNT$ applications marked as critical",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"content": "$1",
|
||||
"example": "3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"applicationsMarkedAsCriticalFail": {
|
||||
"message": "Failed to mark applications as critical"
|
||||
},
|
||||
@@ -298,6 +313,12 @@
|
||||
"membersWithAccessToAtRiskItemsForCriticalApps": {
|
||||
"message": "Members with access to at-risk items for critical applications"
|
||||
},
|
||||
"membersWithAtRiskPasswords": {
|
||||
"message": "Members with at-risk passwords"
|
||||
},
|
||||
"membersWillReceiveNotification": {
|
||||
"message": "Members will receive a notification to resolve at-risk logins through the browser extension."
|
||||
},
|
||||
"membersAtRiskCount": {
|
||||
"message": "$COUNT$ members at-risk",
|
||||
"placeholders": {
|
||||
@@ -391,8 +412,11 @@
|
||||
"prioritizeCriticalApplications": {
|
||||
"message": "Prioritize critical applications"
|
||||
},
|
||||
"atRiskItems": {
|
||||
"message": "At-risk items"
|
||||
"selectCriticalApplicationsDescription": {
|
||||
"message": "Select which applications are most critical to your organization, then assign security tasks to members to resolve risks."
|
||||
},
|
||||
"clickIconToMarkAppAsCritical": {
|
||||
"message": "Click the star icon to mark an app as critical"
|
||||
},
|
||||
"markAsCriticalPlaceholder": {
|
||||
"message": "Mark as critical functionality will be implemented in a future update"
|
||||
@@ -400,15 +424,6 @@
|
||||
"applicationReviewSaved": {
|
||||
"message": "Application review saved"
|
||||
},
|
||||
"applicationsMarkedAsCritical": {
|
||||
"message": "$COUNT$ applications marked as critical",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"content": "$1",
|
||||
"example": "3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"newApplicationsReviewed": {
|
||||
"message": "New applications reviewed"
|
||||
},
|
||||
@@ -880,6 +895,9 @@
|
||||
"favorites": {
|
||||
"message": "Favorites"
|
||||
},
|
||||
"taskSummary": {
|
||||
"message": "Task summary"
|
||||
},
|
||||
"types": {
|
||||
"message": "Types"
|
||||
},
|
||||
@@ -5853,8 +5871,8 @@
|
||||
"autoConfirmSingleOrgRequired": {
|
||||
"message": "Single organization policy required. "
|
||||
},
|
||||
"autoConfirmSingleOrgRequiredDescription": {
|
||||
"message": "Anyone part of more than one organization will have their access revoked until they leave the other organizations."
|
||||
"autoConfirmSingleOrgRequiredDesc": {
|
||||
"message": "All members must only belong to this organization to activate this automation."
|
||||
},
|
||||
"autoConfirmSingleOrgExemption": {
|
||||
"message": "Single organization policy will extend to all roles. "
|
||||
@@ -6586,11 +6604,11 @@
|
||||
"updateWeakMasterPasswordWarning": {
|
||||
"message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour."
|
||||
},
|
||||
"automaticAppLogin": {
|
||||
"message": "Automatically log in users for allowed applications"
|
||||
"automaticAppLoginWithSSO": {
|
||||
"message": "Automatic login with SSO"
|
||||
},
|
||||
"automaticAppLoginDesc": {
|
||||
"message": "Login forms will automatically be filled and submitted for apps launched from your configured identity provider."
|
||||
"automaticAppLoginWithSSODesc": {
|
||||
"message": "Extend SSO security and convenience to unmanaged apps. When users launch an app from your identity provider, their login details are automatically filled and submitted, creating a one-click, secure flow from the identity provider to the app."
|
||||
},
|
||||
"automaticAppLoginIdpHostLabel": {
|
||||
"message": "Identity provider host"
|
||||
@@ -7365,6 +7383,9 @@
|
||||
"accessDenied": {
|
||||
"message": "Access denied. You do not have permission to view this page."
|
||||
},
|
||||
"noPageAccess": {
|
||||
"message": "You do not have access to this page"
|
||||
},
|
||||
"masterPassword": {
|
||||
"message": "Master password"
|
||||
},
|
||||
@@ -9823,6 +9844,9 @@
|
||||
"assignTasks": {
|
||||
"message": "Assign tasks"
|
||||
},
|
||||
"assignTasksToMembers": {
|
||||
"message": "Assign tasks to members for guided resolution"
|
||||
},
|
||||
"assignToCollections": {
|
||||
"message": "Assign to collections"
|
||||
},
|
||||
|
||||
@@ -86,11 +86,11 @@
|
||||
*/
|
||||
@layer components {
|
||||
.tw-h1 {
|
||||
@apply tw-text-3xl tw-font-semibold;
|
||||
@apply tw-text-3xl tw-font-medium;
|
||||
}
|
||||
|
||||
.tw-btn {
|
||||
@apply tw-font-semibold tw-py-1.5 tw-px-3 tw-rounded-full tw-transition tw-border-2 tw-border-solid tw-text-center tw-no-underline focus:tw-outline-none;
|
||||
@apply tw-font-medium tw-py-1.5 tw-px-3 tw-rounded-full tw-transition tw-border-2 tw-border-solid tw-text-center tw-no-underline focus:tw-outline-none;
|
||||
}
|
||||
|
||||
.tw-btn-secondary {
|
||||
@@ -100,7 +100,7 @@
|
||||
}
|
||||
|
||||
.tw-link {
|
||||
@apply tw-font-semibold hover:tw-underline hover:tw-decoration-1;
|
||||
@apply tw-font-medium hover:tw-underline hover:tw-decoration-1;
|
||||
|
||||
@apply tw-text-primary-600 hover:tw-text-primary-700 focus-visible:before:tw-ring-primary-600;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
color: rgb(var(--color-primary-600));
|
||||
}
|
||||
&.active {
|
||||
font-weight: bold;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
> .filter-buttons {
|
||||
.filter-button {
|
||||
color: rgb(var(--color-primary-600));
|
||||
font-weight: bold;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
|
||||
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.
@@ -56,6 +56,7 @@ import {
|
||||
OrganizationReportSummary,
|
||||
ReportStatus,
|
||||
ReportState,
|
||||
ApplicationHealthReportDetail,
|
||||
} from "../../models/report-models";
|
||||
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
|
||||
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
|
||||
@@ -98,18 +99,28 @@ export class RiskInsightsOrchestratorService {
|
||||
enrichedReportData$ = this._enrichedReportDataSubject.asObservable();
|
||||
|
||||
// New applications that haven't been reviewed (reviewedDate === null)
|
||||
newApplications$: Observable<string[]> = this.rawReportData$.pipe(
|
||||
newApplications$: Observable<ApplicationHealthReportDetail[]> = this.rawReportData$.pipe(
|
||||
map((reportState) => {
|
||||
if (!reportState.data?.applicationData) {
|
||||
return [];
|
||||
}
|
||||
return reportState.data.applicationData
|
||||
.filter((app) => app.reviewedDate === null)
|
||||
.map((app) => app.applicationName);
|
||||
const reportApplications = reportState.data?.applicationData || [];
|
||||
|
||||
const newApplications =
|
||||
reportState?.data?.reportData.filter((reportApp) =>
|
||||
reportApplications.some(
|
||||
(app) => app.applicationName == reportApp.applicationName && app.reviewedDate == null,
|
||||
),
|
||||
) || [];
|
||||
return newApplications;
|
||||
}),
|
||||
distinctUntilChanged((prev, curr) => {
|
||||
if (prev.length !== curr.length) {
|
||||
return false;
|
||||
}
|
||||
return prev.every(
|
||||
(app, i) =>
|
||||
app.applicationName === curr[i].applicationName &&
|
||||
app.atRiskPasswordCount === curr[i].atRiskPasswordCount,
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(
|
||||
(prev, curr) => prev.length === curr.length && prev.every((app, i) => app === curr[i]),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
@@ -332,9 +343,12 @@ export class RiskInsightsOrchestratorService {
|
||||
}
|
||||
|
||||
// Create a set for quick lookup of the new critical apps
|
||||
const newCriticalAppNamesSet = new Set(criticalApplications);
|
||||
const newCriticalAppNamesSet = criticalApplications.map((ca) => ({
|
||||
applicationName: ca,
|
||||
isCritical: true,
|
||||
}));
|
||||
const existingApplicationData = report!.applicationData || [];
|
||||
const updatedApplicationData = this._mergeApplicationData(
|
||||
const updatedApplicationData = this._updateApplicationData(
|
||||
existingApplicationData,
|
||||
newCriticalAppNamesSet,
|
||||
);
|
||||
@@ -443,18 +457,18 @@ export class RiskInsightsOrchestratorService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves review status for new applications and optionally marks selected ones as critical.
|
||||
* This method:
|
||||
* 1. Sets reviewedDate to current date for all applications where reviewedDate === null
|
||||
* 2. Sets isCritical = true for applications in the selectedCriticalApps array
|
||||
* Saves review status for new applications and optionally marks
|
||||
* selected ones as critical
|
||||
*
|
||||
* @param selectedCriticalApps Array of application names to mark as critical (can be empty)
|
||||
* @param reviewedApplications Array of application names to mark as reviewed
|
||||
* @returns Observable of updated ReportState
|
||||
*/
|
||||
saveApplicationReviewStatus$(selectedCriticalApps: string[]): Observable<ReportState> {
|
||||
this.logService.info("[RiskInsightsOrchestratorService] Saving application review status", {
|
||||
criticalAppsCount: selectedCriticalApps.length,
|
||||
});
|
||||
saveApplicationReviewStatus$(
|
||||
reviewedApplications: OrganizationReportApplication[],
|
||||
): Observable<ReportState> {
|
||||
this.logService.info(
|
||||
`[RiskInsightsOrchestratorService] Saving application review status for ${reviewedApplications.length} applications`,
|
||||
);
|
||||
|
||||
return this.rawReportData$.pipe(
|
||||
take(1),
|
||||
@@ -464,16 +478,43 @@ export class RiskInsightsOrchestratorService {
|
||||
this._userId$.pipe(filter((userId) => !!userId)),
|
||||
),
|
||||
map(([reportState, organizationDetails, userId]) => {
|
||||
const report = reportState?.data;
|
||||
if (!report) {
|
||||
throwError(() => Error("Tried save reviewed applications without a report"));
|
||||
}
|
||||
|
||||
const existingApplicationData = reportState?.data?.applicationData || [];
|
||||
const updatedApplicationData = this._updateReviewStatusAndCriticalFlags(
|
||||
const updatedApplicationData = this._updateApplicationData(
|
||||
existingApplicationData,
|
||||
selectedCriticalApps,
|
||||
reviewedApplications,
|
||||
);
|
||||
|
||||
// Updated summary data after changing critical apps
|
||||
const updatedSummaryData = this.reportService.getApplicationsSummary(
|
||||
report!.reportData,
|
||||
updatedApplicationData,
|
||||
);
|
||||
// Used for creating metrics with updated application data
|
||||
const manualEnrichedApplications = report!.reportData.map(
|
||||
(application): ApplicationHealthReportDetailEnriched => ({
|
||||
...application,
|
||||
isMarkedAsCritical: this.reportService.isCriticalApplication(
|
||||
application,
|
||||
updatedApplicationData,
|
||||
),
|
||||
}),
|
||||
);
|
||||
// For now, merge the report with the critical marking flag to make the enriched type
|
||||
// We don't care about the individual ciphers in this instance
|
||||
// After the report and enriched report types are consolidated, this mapping can be removed
|
||||
// and the class will expose getCriticalApplications
|
||||
const metrics = this._getReportMetrics(manualEnrichedApplications, updatedSummaryData);
|
||||
|
||||
const updatedState = {
|
||||
...reportState,
|
||||
data: {
|
||||
...reportState.data,
|
||||
summaryData: updatedSummaryData,
|
||||
applicationData: updatedApplicationData,
|
||||
},
|
||||
} as ReportState;
|
||||
@@ -484,9 +525,9 @@ export class RiskInsightsOrchestratorService {
|
||||
criticalApps: updatedApplicationData.filter((app) => app.isCritical).length,
|
||||
});
|
||||
|
||||
return { reportState, organizationDetails, updatedState, userId };
|
||||
return { reportState, organizationDetails, updatedState, userId, metrics };
|
||||
}),
|
||||
switchMap(({ reportState, organizationDetails, updatedState, userId }) => {
|
||||
switchMap(({ reportState, organizationDetails, updatedState, userId, metrics }) => {
|
||||
return from(
|
||||
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
||||
{
|
||||
@@ -506,10 +547,11 @@ export class RiskInsightsOrchestratorService {
|
||||
organizationDetails,
|
||||
updatedState,
|
||||
encryptedData,
|
||||
metrics,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
|
||||
switchMap(({ reportState, organizationDetails, updatedState, encryptedData, metrics }) => {
|
||||
this.logService.debug(
|
||||
`[RiskInsightsOrchestratorService] Persisting review status - report id: ${reportState?.data?.id}`,
|
||||
);
|
||||
@@ -521,26 +563,44 @@ export class RiskInsightsOrchestratorService {
|
||||
return of({ ...reportState });
|
||||
}
|
||||
|
||||
return this.reportApiService
|
||||
.updateRiskInsightsApplicationData$(
|
||||
reportState.data.id,
|
||||
organizationDetails.organizationId,
|
||||
{
|
||||
data: {
|
||||
applicationData: encryptedData.encryptedApplicationData.toSdk(),
|
||||
},
|
||||
// Update applications data with critical marking
|
||||
const updateApplicationsCall = this.reportApiService.updateRiskInsightsApplicationData$(
|
||||
reportState.data.id,
|
||||
organizationDetails.organizationId,
|
||||
{
|
||||
data: {
|
||||
applicationData: encryptedData.encryptedApplicationData.toSdk(),
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
map(() => updatedState),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(
|
||||
"[RiskInsightsOrchestratorService] Failed to save review status",
|
||||
error,
|
||||
);
|
||||
return of({ ...reportState, error: "Failed to save application review status" });
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Update summary after recomputing
|
||||
const updateSummaryCall = this.reportApiService.updateRiskInsightsSummary$(
|
||||
reportState.data.id,
|
||||
organizationDetails.organizationId,
|
||||
{
|
||||
data: {
|
||||
summaryData: encryptedData.encryptedSummaryData.toSdk(),
|
||||
metrics: metrics.toRiskInsightsMetricsData(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe(
|
||||
map(() => updatedState),
|
||||
tap((finalState) => {
|
||||
this._flagForUpdatesSubject.next({
|
||||
...finalState,
|
||||
});
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(
|
||||
"[RiskInsightsOrchestratorService] Failed to save review status",
|
||||
error,
|
||||
);
|
||||
return of({ ...reportState, error: "Failed to save application review status" });
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -752,67 +812,40 @@ export class RiskInsightsOrchestratorService {
|
||||
|
||||
// Updates the existing application data to include critical applications
|
||||
// Does not remove critical applications not in the set
|
||||
private _mergeApplicationData(
|
||||
private _updateApplicationData(
|
||||
existingApplications: OrganizationReportApplication[],
|
||||
criticalApplications: Set<string>,
|
||||
updatedApplications: (Partial<OrganizationReportApplication> & { applicationName: string })[],
|
||||
): OrganizationReportApplication[] {
|
||||
const setToMerge = new Set(criticalApplications);
|
||||
const arrayToMerge = [...updatedApplications];
|
||||
|
||||
const updatedApps = existingApplications.map((app) => {
|
||||
const foundCritical = setToMerge.has(app.applicationName);
|
||||
// Check if there is an updated app
|
||||
const foundUpdatedIndex = arrayToMerge.findIndex(
|
||||
(ua) => ua.applicationName == app.applicationName,
|
||||
);
|
||||
|
||||
if (foundCritical) {
|
||||
setToMerge.delete(app.applicationName);
|
||||
let foundApp: Partial<OrganizationReportApplication> | null = null;
|
||||
// Remove the updated app from the list
|
||||
if (foundUpdatedIndex >= 0) {
|
||||
foundApp = arrayToMerge[foundUpdatedIndex];
|
||||
arrayToMerge.splice(foundUpdatedIndex, 1);
|
||||
}
|
||||
|
||||
return {
|
||||
...app,
|
||||
isCritical: foundCritical || app.isCritical,
|
||||
applicationName: app.applicationName,
|
||||
isCritical: foundApp?.isCritical || app.isCritical,
|
||||
reviewedDate: foundApp?.reviewedDate || app.reviewedDate,
|
||||
};
|
||||
});
|
||||
|
||||
setToMerge.forEach((applicationName) => {
|
||||
updatedApps.push({
|
||||
applicationName,
|
||||
isCritical: true,
|
||||
const newElements: OrganizationReportApplication[] = arrayToMerge.map(
|
||||
(newApp): OrganizationReportApplication => ({
|
||||
applicationName: newApp.applicationName,
|
||||
isCritical: newApp.isCritical ?? false,
|
||||
reviewedDate: null,
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return updatedApps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates review status and critical flags for applications.
|
||||
* Sets reviewedDate for all apps with null reviewedDate.
|
||||
* Sets isCritical flag for apps in the criticalApplications array.
|
||||
*
|
||||
* @param existingApplications Current application data
|
||||
* @param criticalApplications Array of application names to mark as critical
|
||||
* @returns Updated application data with review dates and critical flags
|
||||
*/
|
||||
private _updateReviewStatusAndCriticalFlags(
|
||||
existingApplications: OrganizationReportApplication[],
|
||||
criticalApplications: string[],
|
||||
): OrganizationReportApplication[] {
|
||||
const criticalSet = new Set(criticalApplications);
|
||||
const currentDate = new Date();
|
||||
|
||||
return existingApplications.map((app) => {
|
||||
const shouldMarkCritical = criticalSet.has(app.applicationName);
|
||||
const needsReviewDate = app.reviewedDate === null;
|
||||
|
||||
// Only create new object if changes are needed
|
||||
if (needsReviewDate || shouldMarkCritical) {
|
||||
return {
|
||||
...app,
|
||||
reviewedDate: needsReviewDate ? currentDate : app.reviewedDate,
|
||||
isCritical: shouldMarkCritical || app.isCritical,
|
||||
};
|
||||
}
|
||||
|
||||
return app;
|
||||
});
|
||||
return updatedApps.concat(newElements);
|
||||
}
|
||||
|
||||
// Toggles the isCritical flag on applications via criticalApplicationName
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
DrawerType,
|
||||
RiskInsightsEnrichedData,
|
||||
ReportStatus,
|
||||
ApplicationHealthReportDetail,
|
||||
OrganizationReportApplication,
|
||||
} from "../../models";
|
||||
import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service";
|
||||
|
||||
@@ -38,7 +40,7 @@ export class RiskInsightsDataService {
|
||||
readonly hasCiphers$: Observable<boolean | null> = of(null);
|
||||
|
||||
// New applications that need review (reviewedDate === null)
|
||||
readonly newApplications$: Observable<string[]> = of([]);
|
||||
readonly newApplications$: Observable<ApplicationHealthReportDetail[]> = of([]);
|
||||
|
||||
// ------------------------- Drawer Variables ---------------------
|
||||
// Drawer variables unified into a single BehaviorSubject
|
||||
@@ -257,7 +259,7 @@ export class RiskInsightsDataService {
|
||||
return this.orchestrator.removeCriticalApplication$(hostname);
|
||||
}
|
||||
|
||||
saveApplicationReviewStatus(selectedCriticalApps: string[]) {
|
||||
saveApplicationReviewStatus(selectedCriticalApps: OrganizationReportApplication[]) {
|
||||
return this.orchestrator.saveApplicationReviewStatus$(selectedCriticalApps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
export class AutomaticAppLoginPolicy extends BasePolicyEditDefinition {
|
||||
name = "automaticAppLogin";
|
||||
description = "automaticAppLoginDesc";
|
||||
name = "automaticAppLoginWithSSO";
|
||||
description = "automaticAppLoginWithSSODesc";
|
||||
type = PolicyType.AutomaticAppLogIn;
|
||||
component = AutomaticAppLoginPolicyComponent;
|
||||
}
|
||||
|
||||
@@ -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,10 +1,11 @@
|
||||
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, lastValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
AllActivitiesService,
|
||||
ApplicationHealthReportDetail,
|
||||
ReportStatus,
|
||||
RiskInsightsDataService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
@@ -13,6 +14,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
@@ -21,7 +23,7 @@ import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.co
|
||||
|
||||
import { ActivityCardComponent } from "./activity-card.component";
|
||||
import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component";
|
||||
import { NewApplicationsDialogComponent } from "./new-applications-dialog.component";
|
||||
import { NewApplicationsDialogComponent } from "./application-review-dialog/new-applications-dialog.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@@ -42,7 +44,7 @@ export class AllActivityComponent implements OnInit {
|
||||
totalCriticalAppsCount = 0;
|
||||
totalCriticalAppsAtRiskCount = 0;
|
||||
newApplicationsCount = 0;
|
||||
newApplications: string[] = [];
|
||||
newApplications: ApplicationHealthReportDetail[] = [];
|
||||
passwordChangeMetricHasProgressBar = false;
|
||||
allAppsHaveReviewDate = false;
|
||||
isAllCaughtUp = false;
|
||||
@@ -129,27 +131,38 @@ export class AllActivityComponent implements OnInit {
|
||||
* Handles the review new applications button click.
|
||||
* Opens a dialog showing the list of new applications that can be marked as critical.
|
||||
*/
|
||||
onReviewNewApplications = async () => {
|
||||
async onReviewNewApplications() {
|
||||
const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId");
|
||||
|
||||
if (!organizationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass organizationId via dialog data instead of having the dialog retrieve it from route.
|
||||
// This ensures organizationId is immediately available when dialog opens, preventing
|
||||
// timing issues where the dialog's checkForTasksToAssign() method runs before
|
||||
// organizationId is populated via async route subscription.
|
||||
const dialogRef = NewApplicationsDialogComponent.open(this.dialogService, {
|
||||
newApplications: this.newApplications,
|
||||
organizationId: organizationId as OrganizationId,
|
||||
});
|
||||
|
||||
await firstValueFrom(dialogRef.closed);
|
||||
};
|
||||
await lastValueFrom(dialogRef.closed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the "View at-risk members" link click.
|
||||
* Opens the at-risk members drawer for critical applications only.
|
||||
*/
|
||||
onViewAtRiskMembers = async () => {
|
||||
async onViewAtRiskMembers() {
|
||||
await this.dataService.setDrawerForCriticalAtRiskMembers("activityTabAtRiskMembers");
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the "View at-risk applications" link click.
|
||||
* Opens the at-risk applications drawer for critical applications only.
|
||||
*/
|
||||
onViewAtRiskApplications = async () => {
|
||||
async onViewAtRiskApplications() {
|
||||
await this.dataService.setDrawerForCriticalAtRiskApps("activityTabAtRiskApplications");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,97 @@
|
||||
<bit-dialog [dialogSize]="'large'">
|
||||
<span bitDialogTitle>
|
||||
{{
|
||||
currentView() === DialogView.SelectApplications
|
||||
? ("prioritizeCriticalApplications" | i18n)
|
||||
: ("assignTasksToMembers" | i18n)
|
||||
}}
|
||||
</span>
|
||||
|
||||
<div bitDialogContent>
|
||||
@if (currentView() === DialogView.SelectApplications) {
|
||||
<div>
|
||||
<p bitTypography="body1" class="tw-mb-5">
|
||||
{{ "selectCriticalApplicationsDescription" | i18n }}
|
||||
</p>
|
||||
|
||||
<div class="tw-flex tw-items-center tw-gap-2.5 tw-mb-5">
|
||||
<i class="bwi bwi-star-f tw-text-xl" aria-hidden="true"></i>
|
||||
<p bitTypography="helper" class="tw-text-muted tw-mb-0">
|
||||
{{ "clickIconToMarkAppAsCritical" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dirt-review-applications-view
|
||||
[applications]="getApplications()"
|
||||
[selectedApplications]="selectedApplications()"
|
||||
(onToggleSelection)="toggleSelection($event)"
|
||||
(onToggleAll)="toggleAll()"
|
||||
></dirt-review-applications-view>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (currentView() === DialogView.AssignTasks) {
|
||||
<dirt-assign-tasks-view
|
||||
[criticalApplicationsCount]="selectedApplications().size"
|
||||
[totalApplicationsCount]="this.dialogParams.newApplications.length"
|
||||
[atRiskCriticalMembersCount]="atRiskCriticalMembersCount()"
|
||||
>
|
||||
</dirt-assign-tasks-view>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (currentView() === DialogView.SelectApplications) {
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
size="small"
|
||||
buttonType="primary"
|
||||
(click)="handleMarkAsCritical()"
|
||||
[disabled]="markingAsCritical()"
|
||||
[loading]="markingAsCritical()"
|
||||
[attr.aria-label]="'markAsCritical' | i18n"
|
||||
>
|
||||
{{ "markAsCritical" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
size="small"
|
||||
buttonType="secondary"
|
||||
[bitDialogClose]="false"
|
||||
[attr.aria-label]="'cancel' | i18n"
|
||||
>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
}
|
||||
@if (currentView() == DialogView.AssignTasks) {
|
||||
<!-- Footer: Action Buttons -->
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
size="small"
|
||||
buttonType="primary"
|
||||
(click)="handleAssignTasks()"
|
||||
[disabled]="saving()"
|
||||
[loading]="saving()"
|
||||
[attr.aria-label]="'assignTasks' | i18n"
|
||||
>
|
||||
{{ "assignTasks" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
size="small"
|
||||
buttonType="secondary"
|
||||
(click)="onBack()"
|
||||
[disabled]="saving()"
|
||||
[attr.aria-label]="'back' | i18n"
|
||||
>
|
||||
{{ "back" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
}
|
||||
</bit-dialog>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user