mirror of
https://github.com/bitwarden/browser
synced 2026-02-01 09:13:54 +00:00
Merge branch 'main' into auth/pm-26209/bugfix-desktop-error-on-auth-request-approval
This commit is contained in:
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -119,9 +119,9 @@ jobs:
|
||||
run: cargo sort --workspace --check
|
||||
|
||||
- name: Install cargo-deny
|
||||
uses: taiki-e/install-action@v2
|
||||
uses: taiki-e/install-action@81ee1d48d9194cdcab880cbdc7d36e87d39874cb # v2.62.45
|
||||
with:
|
||||
tool: cargo-deny
|
||||
tool: cargo-deny@0.18.5
|
||||
|
||||
- name: Run cargo deny
|
||||
working-directory: ./apps/desktop/desktop_native
|
||||
|
||||
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"
|
||||
},
|
||||
@@ -4980,6 +4974,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultLabelWithValue": {
|
||||
"message": "Default ( $VALUE$ )",
|
||||
"description": "A label that indicates the default value for a field with the current default value in parentheses.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"content": "$1",
|
||||
"example": "Base domain"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showMatchDetection": {
|
||||
"message": "Show match detection $WEBSITE$",
|
||||
"placeholders": {
|
||||
@@ -5772,6 +5776,30 @@
|
||||
"atRiskLoginsSecured": {
|
||||
"message": "Great job securing your at-risk logins!"
|
||||
},
|
||||
"upgradeNow": {
|
||||
"message": "Upgrade now"
|
||||
},
|
||||
"builtInAuthenticator": {
|
||||
"message": "Built-in authenticator"
|
||||
},
|
||||
"secureFileStorage": {
|
||||
"message": "Secure file storage"
|
||||
},
|
||||
"emergencyAccess": {
|
||||
"message": "Emergency access"
|
||||
},
|
||||
"breachMonitoring": {
|
||||
"message": "Breach monitoring"
|
||||
},
|
||||
"andMoreFeatures": {
|
||||
"message": "And more!"
|
||||
},
|
||||
"planDescPremium": {
|
||||
"message": "Complete online security"
|
||||
},
|
||||
"upgradeToPremium": {
|
||||
"message": "Upgrade to Premium"
|
||||
},
|
||||
"settingDisabledByPolicy": {
|
||||
"message": "This setting is disabled by your organization's policy.",
|
||||
"description": "This hint text is displayed when a user setting is disabled due to an organization policy."
|
||||
|
||||
@@ -68,7 +68,7 @@ const actionButtonStyles = ({
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
|
||||
${disabled || isLoading
|
||||
? `
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -115,7 +115,7 @@ const notificationConfirmationButtonTextStyles = (theme: Theme) => css`
|
||||
${baseTextStyles}
|
||||
|
||||
color: ${themes[theme].primary[600]};
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const notificationHeaderMessageStyles = (theme: Theme) => css`
|
||||
line-height: 28px;
|
||||
white-space: nowrap;
|
||||
color: ${themes[theme].text.main};
|
||||
font-family: Roboto, sans-serif;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
@@ -94,7 +94,7 @@ const optionsLabelStyles = ({ theme }: { theme: Theme }) => css`
|
||||
user-select: none;
|
||||
padding: 0.375rem ${spacing["3"]};
|
||||
color: ${themes[theme].text.muted};
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
export const optionsMenuItemMaxWidth = 260;
|
||||
|
||||
@@ -34,7 +34,7 @@ const actionRowStyles = (theme: Theme) => css`
|
||||
min-height: 40px;
|
||||
text-align: left;
|
||||
color: ${themes[theme].primary["600"]};
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
filter,
|
||||
firstValueFrom,
|
||||
fromEvent,
|
||||
fromEventPattern,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
Subject,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import { fromChromeEvent } from "../../../platform/browser/from-chrome-event";
|
||||
// FIXME (PM-22628): Popup imports are forbidden in background
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { closeFido2Popout, openFido2Popout } from "../../../vault/popup/utils/vault-popout-window";
|
||||
@@ -232,12 +233,8 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
}
|
||||
});
|
||||
|
||||
this.windowClosed$ = fromEventPattern(
|
||||
// FIXME: Make sure that is does not cause a memory leak in Safari or use BrowserApi.AddListener
|
||||
// and test that it doesn't break. Tracking Ticket: https://bitwarden.atlassian.net/browse/PM-4735
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(handler: any) => chrome.windows.onRemoved.addListener(handler),
|
||||
(handler: any) => chrome.windows.onRemoved.removeListener(handler),
|
||||
this.windowClosed$ = fromChromeEvent(chrome.windows.onRemoved).pipe(
|
||||
map(([windowId]) => windowId),
|
||||
);
|
||||
|
||||
BrowserFido2UserInterfaceSession.sendMessage({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<!-- eslint-disable tailwindcss/no-custom-classname -->
|
||||
<!doctype html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Bitwarden</title>
|
||||
|
||||
@@ -82,7 +82,7 @@ body * {
|
||||
width: 100%;
|
||||
font-family: $font-family-sans-serif;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -187,7 +187,7 @@ body * {
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
font-family: $font-family-sans-serif;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.025rem;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="tw-size-[95px] tw-content-center">
|
||||
<bit-icon [icon]="sendCreatedIcon"></bit-icon>
|
||||
</div>
|
||||
<h3 tabindex="0" appAutofocus class="tw-font-semibold">
|
||||
<h3 tabindex="0" appAutofocus class="tw-font-medium">
|
||||
{{ "createdSendSuccessfully" | i18n }}
|
||||
</h3>
|
||||
<p class="tw-text-center">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -181,10 +181,21 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not show the exact match dialog when the default match strategy is Exact and autofill confirmation is not to be shown", async () => {
|
||||
// autofill confirmation dialog is not shown when either the feature flag is disabled or search text is not present
|
||||
uriMatchStrategy$.next(UriMatchStrategy.Exact);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" });
|
||||
await component.doAutofill();
|
||||
|
||||
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("autofill confirmation dialog", () => {
|
||||
beforeEach(() => {
|
||||
// autofill confirmation dialog is shown when feature flag is enabled and search text is present
|
||||
featureFlag$.next(true);
|
||||
hasSearchText$.next(true);
|
||||
uriMatchStrategy$.next(UriMatchStrategy.Domain);
|
||||
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
@@ -243,47 +254,122 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
});
|
||||
|
||||
describe("URI match strategy handling", () => {
|
||||
it("shows the exact match dialog when the uri match strategy is Exact", async () => {
|
||||
uriMatchStrategy$.next(UriMatchStrategy.Exact);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
|
||||
describe("when the default URI match strategy is Exact", () => {
|
||||
beforeEach(() => {
|
||||
uriMatchStrategy$.next(UriMatchStrategy.Exact);
|
||||
});
|
||||
|
||||
await component.doAutofill();
|
||||
it("shows the exact match dialog and not the password dialog", async () => {
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
|
||||
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1);
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.objectContaining({ key: "cannotAutofill" }),
|
||||
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
|
||||
type: "info",
|
||||
}),
|
||||
);
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
await component.doAutofill();
|
||||
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1);
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.objectContaining({ key: "cannotAutofill" }),
|
||||
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
|
||||
type: "info",
|
||||
}),
|
||||
);
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
expect(passwordRepromptService.passwordRepromptCheck).not.toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the exact match dialog and not the password reprompt dialog when the uri match strategy is Exact and the item has master password reprompt enabled", async () => {
|
||||
uriMatchStrategy$.next(UriMatchStrategy.Exact);
|
||||
describe("when the default URI match strategy is not Exact", () => {
|
||||
beforeEach(() => {
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
|
||||
uriMatchStrategy$.next(UriMatchStrategy.Domain);
|
||||
});
|
||||
it("does not show the exact match dialog", async () => {
|
||||
cipherService.getFullCipherView.mockImplementation(async (c) => ({
|
||||
...baseCipher,
|
||||
...c,
|
||||
login: {
|
||||
...baseCipher.login,
|
||||
uris: [
|
||||
{ uri: "https://one.example.com", match: UriMatchStrategy.Exact },
|
||||
{ uri: "https://page.example.com", match: UriMatchStrategy.Domain },
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the exact match dialog when the cipher has a single uri with a match strategy of Exact", async () => {
|
||||
cipherService.getFullCipherView.mockImplementation(async (c) => ({
|
||||
...baseCipher,
|
||||
...c,
|
||||
login: {
|
||||
...baseCipher.login,
|
||||
uris: [{ uri: "https://one.example.com", match: UriMatchStrategy.Exact }],
|
||||
},
|
||||
}));
|
||||
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.objectContaining({ key: "cannotAutofill" }),
|
||||
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
|
||||
type: "info",
|
||||
}),
|
||||
);
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show the exact match dialog when the cipher has no uris", async () => {
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
|
||||
cipherService.getFullCipherView.mockImplementation(async (c) => ({
|
||||
...baseCipher,
|
||||
...c,
|
||||
login: {
|
||||
...baseCipher.login,
|
||||
uris: [],
|
||||
},
|
||||
}));
|
||||
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1);
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.objectContaining({ key: "cannotAutofill" }),
|
||||
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
|
||||
type: "info",
|
||||
}),
|
||||
);
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
expect(passwordRepromptService.passwordRepromptCheck).not.toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not show the exact match dialog when the cipher has a uri with a match strategy of Exact and a uri with a match strategy of Domain", async () => {
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
|
||||
cipherService.getFullCipherView.mockImplementation(async (c) => ({
|
||||
...baseCipher,
|
||||
...c,
|
||||
login: {
|
||||
...baseCipher.login,
|
||||
uris: [
|
||||
{ uri: "https://one.example.com", match: UriMatchStrategy.Exact },
|
||||
{ uri: "https://page.example.com", match: UriMatchStrategy.Domain },
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the 'Fill and Save' button when showAutofillConfirmation$ is true", async () => {
|
||||
// Enable both feature flag and search text → makes showAutofillConfirmation$ true
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
|
||||
@@ -202,8 +202,17 @@ export class ItemMoreOptionsComponent {
|
||||
async doAutofill() {
|
||||
const cipher = await this.cipherService.getFullCipherView(this.cipher);
|
||||
|
||||
const uris = cipher.login?.uris ?? [];
|
||||
const cipherHasAllExactMatchLoginUris =
|
||||
uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact);
|
||||
|
||||
const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$);
|
||||
const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$);
|
||||
if (uriMatchStrategy === UriMatchStrategy.Exact) {
|
||||
|
||||
if (
|
||||
showAutofillConfirmation &&
|
||||
(cipherHasAllExactMatchLoginUris || uriMatchStrategy === UriMatchStrategy.Exact)
|
||||
) {
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: "cannotAutofill" },
|
||||
content: { key: "cannotAutofillExactMatch" },
|
||||
@@ -218,8 +227,6 @@ export class ItemMoreOptionsComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$);
|
||||
|
||||
if (!showAutofillConfirmation) {
|
||||
await this.vaultPopupAutofillService.doAutofill(cipher, true, true);
|
||||
return;
|
||||
|
||||
@@ -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",
|
||||
|
||||
37
apps/desktop/desktop_native/Cargo.lock
generated
37
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",
|
||||
]
|
||||
|
||||
@@ -900,19 +900,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "5.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -1533,12 +1520,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.3"
|
||||
@@ -1554,7 +1535,7 @@ version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.3",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1719,7 +1700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.15.3",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1891,7 +1872,6 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"desktop_core",
|
||||
"futures",
|
||||
"oslog",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -2379,17 +2359,6 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oslog"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"dashmap",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
|
||||
@@ -41,13 +41,11 @@ interprocess = "=2.2.1"
|
||||
keytar = "=0.1.6"
|
||||
libc = "=0.2.172"
|
||||
linux-keyutils = "=0.2.4"
|
||||
log = "=0.4.25"
|
||||
memsec = "=0.7.0"
|
||||
napi = "=2.16.17"
|
||||
napi-build = "=2.2.0"
|
||||
napi-derive = "=2.16.13"
|
||||
oo7 = "=0.4.3"
|
||||
oslog = "=0.2.0"
|
||||
pin-project = "=1.1.10"
|
||||
pkcs8 = "=0.10.2"
|
||||
rand = "=0.9.1"
|
||||
@@ -60,7 +58,6 @@ security-framework-sys = "=2.15.0"
|
||||
serde = "=1.0.209"
|
||||
serde_json = "=1.0.127"
|
||||
sha2 = "=0.10.8"
|
||||
simplelog = "=0.12.2"
|
||||
ssh-encoding = "=0.2.0"
|
||||
ssh-key = { version = "=0.6.7", default-features = false }
|
||||
sysinfo = "=0.35.0"
|
||||
@@ -86,10 +83,13 @@ zbus_polkit = "=5.0.0"
|
||||
zeroizing-alloc = "=0.1.0"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
disallowed-macros = "deny"
|
||||
|
||||
# Dis-allow println and eprintln, which are typically used in debugging.
|
||||
# Use `tracing` and `tracing-subscriber` crates for observability needs.
|
||||
print_stderr = "deny"
|
||||
print_stdout = "deny"
|
||||
|
||||
string_slice = "warn"
|
||||
unused_async = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
@@ -21,12 +21,11 @@ serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tokio-util = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-oslog = "0.3.0"
|
||||
tracing-subscriber = { workspace = true }
|
||||
uniffi = { workspace = true, features = ["cli"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
oslog = { workspace = true }
|
||||
tracing-oslog = "0.3.0"
|
||||
|
||||
[build-dependencies]
|
||||
uniffi = { workspace = true, features = ["build"] }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"**/node_modules/@bitwarden/desktop-napi/index.js",
|
||||
"**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node"
|
||||
],
|
||||
"electronVersion": "36.9.3",
|
||||
"electronVersion": "37.7.0",
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.10.2",
|
||||
"version": "2025.11.0",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
<form [bitSubmit]="submit" [formGroup]="setShortcutForm">
|
||||
<bit-dialog>
|
||||
<div class="tw-font-semibold" bitDialogTitle>
|
||||
<div class="tw-font-medium" bitDialogTitle>
|
||||
{{ "typeShortcut" | i18n }}
|
||||
</div>
|
||||
<div bitDialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
import { stringIsNotUndefinedNullAndEmpty } from "../../utils";
|
||||
import { AutotypeVaultData } from "../models/autotype-vault-data";
|
||||
import { AutotypeKeyboardShortcut } from "../models/main-autotype-keyboard-shortcut";
|
||||
|
||||
export class MainDesktopAutotypeService {
|
||||
@@ -47,18 +48,12 @@ export class MainDesktopAutotypeService {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("autofill.completeAutotypeRequest", (event, data) => {
|
||||
const { response } = data;
|
||||
|
||||
ipcMain.on("autofill.completeAutotypeRequest", (_event, vaultData: AutotypeVaultData) => {
|
||||
if (
|
||||
stringIsNotUndefinedNullAndEmpty(response.username) &&
|
||||
stringIsNotUndefinedNullAndEmpty(response.password)
|
||||
stringIsNotUndefinedNullAndEmpty(vaultData.username) &&
|
||||
stringIsNotUndefinedNullAndEmpty(vaultData.password)
|
||||
) {
|
||||
this.doAutotype(
|
||||
response.username,
|
||||
response.password,
|
||||
this.autotypeKeyboardShortcut.getArrayFormat(),
|
||||
);
|
||||
this.doAutotype(vaultData, this.autotypeKeyboardShortcut.getArrayFormat());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -89,8 +84,9 @@ export class MainDesktopAutotypeService {
|
||||
: this.logService.info("Enabling autotype failed.");
|
||||
}
|
||||
|
||||
private doAutotype(username: string, password: string, keyboardShortcut: string[]) {
|
||||
const inputPattern = username + "\t" + password;
|
||||
private doAutotype(vaultData: AutotypeVaultData, keyboardShortcut: string[]) {
|
||||
const TAB = "\t";
|
||||
const inputPattern = vaultData.username + TAB + vaultData.password;
|
||||
const inputArray = new Array<number>(inputPattern.length);
|
||||
|
||||
for (let i = 0; i < inputPattern.length; i++) {
|
||||
|
||||
8
apps/desktop/src/autofill/models/autotype-vault-data.ts
Normal file
8
apps/desktop/src/autofill/models/autotype-vault-data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Vault data used in autotype operations.
|
||||
* `username` and `password` are guaranteed to be not null/undefined.
|
||||
*/
|
||||
export interface AutotypeVaultData {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import type { autofill } from "@bitwarden/desktop-napi";
|
||||
import { Command } from "../platform/main/autofill/command";
|
||||
import { RunCommandParams, RunCommandResult } from "../platform/main/autofill/native-autofill.main";
|
||||
|
||||
import { AutotypeVaultData } from "./models/autotype-vault-data";
|
||||
|
||||
export default {
|
||||
runCommand: <C extends Command>(params: RunCommandParams<C>): Promise<RunCommandResult<C>> =>
|
||||
ipcRenderer.invoke("autofill.runCommand", params),
|
||||
@@ -133,10 +135,7 @@ export default {
|
||||
listenAutotypeRequest: (
|
||||
fn: (
|
||||
windowTitle: string,
|
||||
completeCallback: (
|
||||
error: Error | null,
|
||||
response: { username?: string; password?: string },
|
||||
) => void,
|
||||
completeCallback: (error: Error | null, response: AutotypeVaultData | null) => void,
|
||||
) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
@@ -149,7 +148,7 @@ export default {
|
||||
) => {
|
||||
const { windowTitle } = data;
|
||||
|
||||
fn(windowTitle, (error, response) => {
|
||||
fn(windowTitle, (error, vaultData) => {
|
||||
if (error) {
|
||||
ipcRenderer.send("autofill.completeError", {
|
||||
windowTitle,
|
||||
@@ -157,11 +156,9 @@ export default {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ipcRenderer.send("autofill.completeAutotypeRequest", {
|
||||
windowTitle,
|
||||
response,
|
||||
});
|
||||
if (vaultData !== null) {
|
||||
ipcRenderer.send("autofill.completeAutotypeRequest", vaultData);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { getAutotypeVaultData } from "./desktop-autotype.service";
|
||||
|
||||
describe("getAutotypeVaultData", () => {
|
||||
it("should return vault data when cipher has username and password", () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.login.username = "foo";
|
||||
cipherView.login.password = "bar";
|
||||
|
||||
const [error, vaultData] = getAutotypeVaultData(cipherView);
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(vaultData?.username).toEqual("foo");
|
||||
expect(vaultData?.password).toEqual("bar");
|
||||
});
|
||||
|
||||
it("should return error when firstCipher is undefined", () => {
|
||||
const cipherView = undefined;
|
||||
const [error, vaultData] = getAutotypeVaultData(cipherView);
|
||||
|
||||
expect(vaultData).toBeNull();
|
||||
expect(error).toBeDefined();
|
||||
expect(error?.message).toEqual("No matching vault item.");
|
||||
});
|
||||
|
||||
it("should return error when username is undefined", () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.login.username = undefined;
|
||||
cipherView.login.password = "bar";
|
||||
|
||||
const [error, vaultData] = getAutotypeVaultData(cipherView);
|
||||
|
||||
expect(vaultData).toBeNull();
|
||||
expect(error).toBeDefined();
|
||||
expect(error?.message).toEqual("Vault item is undefined.");
|
||||
});
|
||||
|
||||
it("should return error when password is undefined", () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.login.username = "foo";
|
||||
cipherView.login.password = undefined;
|
||||
|
||||
const [error, vaultData] = getAutotypeVaultData(cipherView);
|
||||
|
||||
expect(vaultData).toBeNull();
|
||||
expect(error).toBeDefined();
|
||||
expect(error?.message).toEqual("Vault item is undefined.");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AutotypeVaultData } from "../models/autotype-vault-data";
|
||||
|
||||
import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service";
|
||||
|
||||
export const defaultWindowsAutotypeKeyboardShortcut: string[] = ["Control", "Shift", "B"];
|
||||
@@ -27,6 +29,8 @@ export const AUTOTYPE_ENABLED = new KeyDefinition<boolean | null>(
|
||||
{ deserializer: (b) => b },
|
||||
);
|
||||
|
||||
export type Result<T, E = Error> = [E, null] | [null, T];
|
||||
|
||||
/*
|
||||
Valid windows shortcut keys: Control, Alt, Super, Shift, letters A - Z
|
||||
Valid macOS shortcut keys: Control, Alt, Command, Shift, letters A - Z
|
||||
@@ -63,11 +67,8 @@ export class DesktopAutotypeService {
|
||||
ipc.autofill.listenAutotypeRequest(async (windowTitle, callback) => {
|
||||
const possibleCiphers = await this.matchCiphersToWindowTitle(windowTitle);
|
||||
const firstCipher = possibleCiphers?.at(0);
|
||||
|
||||
return callback(null, {
|
||||
username: firstCipher?.login?.username,
|
||||
password: firstCipher?.login?.password,
|
||||
});
|
||||
const [error, vaultData] = getAutotypeVaultData(firstCipher);
|
||||
callback(error, vaultData);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,3 +177,23 @@ export class DesktopAutotypeService {
|
||||
return possibleCiphers;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return an `AutotypeVaultData` object or an `Error` if the
|
||||
* cipher or vault data within are undefined.
|
||||
*/
|
||||
export function getAutotypeVaultData(
|
||||
cipherView: CipherView | undefined,
|
||||
): Result<AutotypeVaultData> {
|
||||
if (!cipherView) {
|
||||
return [Error("No matching vault item."), null];
|
||||
} else if (cipherView.login.username === undefined || cipherView.login.password === undefined) {
|
||||
return [Error("Vault item is undefined."), null];
|
||||
} else {
|
||||
const vaultData: AutotypeVaultData = {
|
||||
username: cipherView.login.username,
|
||||
password: cipherView.login.password,
|
||||
};
|
||||
return [null, vaultData];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<form [bitSubmit]="submit" [formGroup]="approveSshRequestForm">
|
||||
<bit-dialog>
|
||||
<div class="tw-font-semibold" bitDialogTitle>{{ "sshkeyApprovalTitle" | i18n }}</div>
|
||||
<div class="tw-font-medium" bitDialogTitle>{{ "sshkeyApprovalTitle" | i18n }}</div>
|
||||
<div bitDialogContent>
|
||||
<app-callout
|
||||
type="warning"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
FakeAccountService,
|
||||
FakeStateProvider,
|
||||
mockAccountServiceWith,
|
||||
} from "@bitwarden/common/spec";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import {
|
||||
PREMIUM_INTEREST_KEY,
|
||||
WebPremiumInterestStateService,
|
||||
} from "./web-premium-interest-state.service";
|
||||
|
||||
describe("WebPremiumInterestStateService", () => {
|
||||
let service: WebPremiumInterestStateService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let accountService: FakeAccountService;
|
||||
|
||||
const mockUserId = newGuid() as UserId;
|
||||
const mockUserEmail = "user@example.com";
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail });
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
service = new WebPremiumInterestStateService(stateProvider);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getPremiumInterest", () => {
|
||||
it("should throw an error when userId is not provided", async () => {
|
||||
const promise = service.getPremiumInterest(null);
|
||||
|
||||
await expect(promise).rejects.toThrow("UserId is required. Cannot get 'premiumInterest'.");
|
||||
});
|
||||
|
||||
it("should return null when no value is set", async () => {
|
||||
const result = await service.getPremiumInterest(mockUserId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return true when value is set to true", async () => {
|
||||
await stateProvider.setUserState(PREMIUM_INTEREST_KEY, true, mockUserId);
|
||||
|
||||
const result = await service.getPremiumInterest(mockUserId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when value is set to false", async () => {
|
||||
await stateProvider.setUserState(PREMIUM_INTEREST_KEY, false, mockUserId);
|
||||
|
||||
const result = await service.getPremiumInterest(mockUserId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should use getUserState$ to retrieve the value", async () => {
|
||||
const getUserStateSpy = jest.spyOn(stateProvider, "getUserState$");
|
||||
await stateProvider.setUserState(PREMIUM_INTEREST_KEY, true, mockUserId);
|
||||
|
||||
await service.getPremiumInterest(mockUserId);
|
||||
|
||||
expect(getUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, mockUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPremiumInterest", () => {
|
||||
it("should throw an error when userId is not provided", async () => {
|
||||
const promise = service.setPremiumInterest(null, true);
|
||||
|
||||
await expect(promise).rejects.toThrow("UserId is required. Cannot set 'premiumInterest'.");
|
||||
});
|
||||
|
||||
it("should set the value to true", async () => {
|
||||
await service.setPremiumInterest(mockUserId, true);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should set the value to false", async () => {
|
||||
await service.setPremiumInterest(mockUserId, false);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should update an existing value", async () => {
|
||||
await service.setPremiumInterest(mockUserId, true);
|
||||
await service.setPremiumInterest(mockUserId, false);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should use setUserState to store the value", async () => {
|
||||
const setUserStateSpy = jest.spyOn(stateProvider, "setUserState");
|
||||
|
||||
await service.setPremiumInterest(mockUserId, true);
|
||||
|
||||
expect(setUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, true, mockUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearPremiumInterest", () => {
|
||||
it("should throw an error when userId is not provided", async () => {
|
||||
const promise = service.clearPremiumInterest(null);
|
||||
|
||||
await expect(promise).rejects.toThrow("UserId is required. Cannot clear 'premiumInterest'.");
|
||||
});
|
||||
|
||||
it("should clear the value by setting it to null", async () => {
|
||||
await service.setPremiumInterest(mockUserId, true);
|
||||
await service.clearPremiumInterest(mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should use setUserState with null to clear the value", async () => {
|
||||
const setUserStateSpy = jest.spyOn(stateProvider, "setUserState");
|
||||
await service.setPremiumInterest(mockUserId, true);
|
||||
|
||||
await service.clearPremiumInterest(mockUserId);
|
||||
|
||||
expect(setUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, null, mockUserId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
|
||||
import { BILLING_MEMORY, StateProvider, UserKeyDefinition } from "@bitwarden/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export const PREMIUM_INTEREST_KEY = new UserKeyDefinition<boolean>(
|
||||
BILLING_MEMORY,
|
||||
"premiumInterest",
|
||||
{
|
||||
deserializer: (value: boolean) => value,
|
||||
clearOn: ["lock", "logout"],
|
||||
},
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
export class WebPremiumInterestStateService implements PremiumInterestStateService {
|
||||
constructor(private stateProvider: StateProvider) {}
|
||||
|
||||
async getPremiumInterest(userId: UserId): Promise<boolean | null> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot get 'premiumInterest'.");
|
||||
}
|
||||
|
||||
return await firstValueFrom(this.stateProvider.getUserState$(PREMIUM_INTEREST_KEY, userId));
|
||||
}
|
||||
|
||||
async setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot set 'premiumInterest'.");
|
||||
}
|
||||
|
||||
await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, premiumInterest, userId);
|
||||
}
|
||||
|
||||
async clearPremiumInterest(userId: UserId): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot clear 'premiumInterest'.");
|
||||
}
|
||||
|
||||
await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, null, userId);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth
|
||||
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
|
||||
import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password";
|
||||
import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
|
||||
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
|
||||
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
||||
import {
|
||||
CLIENT_TYPE,
|
||||
@@ -57,6 +58,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
@@ -96,6 +98,7 @@ import { NoopSdkLoadService } from "@bitwarden/common/platform/services/sdk/noop
|
||||
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
||||
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
|
||||
import {
|
||||
DefaultThemeStateService,
|
||||
ThemeStateService,
|
||||
@@ -129,6 +132,7 @@ import {
|
||||
WebSetInitialPasswordService,
|
||||
} from "../auth";
|
||||
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
|
||||
import { WebPremiumInterestStateService } from "../billing/services/premium-interest/web-premium-interest-state.service";
|
||||
import { HtmlStorageService } from "../core/html-storage.service";
|
||||
import { I18nService } from "../core/i18n.service";
|
||||
import { WebFileDownloadService } from "../core/web-file-download.service";
|
||||
@@ -410,7 +414,21 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: PremiumUpgradePromptService,
|
||||
useClass: WebVaultPremiumUpgradePromptService,
|
||||
deps: [DialogService, Router],
|
||||
deps: [
|
||||
DialogService,
|
||||
ConfigService,
|
||||
AccountService,
|
||||
ApiService,
|
||||
SyncService,
|
||||
BillingAccountProfileStateService,
|
||||
PlatformUtilsService,
|
||||
Router,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: PremiumInterestStateService,
|
||||
useClass: WebPremiumInterestStateService,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AuthRequestAnsweringService,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user