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

Merge branch 'main' into autofill/pm-17641/fix-ssh-agent-default-socket-path

This commit is contained in:
neuronull
2025-09-04 15:04:32 -06:00
208 changed files with 9833 additions and 3433 deletions

2
.github/CODEOWNERS vendored
View File

@@ -29,6 +29,7 @@ libs/common/src/auth @bitwarden/team-auth-dev
apps/browser/src/tools @bitwarden/team-tools-dev
apps/cli/src/tools @bitwarden/team-tools-dev
apps/desktop/src/app/tools @bitwarden/team-tools-dev
apps/desktop/desktop_native/bitwarden_chromium_importer @bitwarden/team-tools-dev
apps/web/src/app/tools @bitwarden/team-tools-dev
libs/angular/src/tools @bitwarden/team-tools-dev
libs/common/src/models/export @bitwarden/team-tools-dev
@@ -215,3 +216,4 @@ apps/web/src/locales/en/messages.json
**/tsconfig.json @bitwarden/team-platform-dev
**/jest.config.js @bitwarden/team-platform-dev
**/project.jsons @bitwarden/team-platform-dev
libs/pricing @bitwarden/team-billing-dev

View File

@@ -123,11 +123,20 @@ jobs:
build-source:
name: Build browser source
name: Build browser source - ${{matrix.license_type.readable}}
runs-on: ubuntu-22.04
needs:
- setup
- locales-test
strategy:
matrix:
license_type:
- include_bitwarden_license_folder: false
archive_name_prefix: ""
readable: "open source license"
- include_bitwarden_license_folder: true
archive_name_prefix: "bit-"
readable: "commercial license"
env:
_BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -166,6 +175,12 @@ jobs:
mkdir -p browser-source/apps/browser
cp -r apps/browser/* browser-source/apps/browser
# Copy bitwarden_license/bit-browser to the Browser source directory
if [[ ${{matrix.license_type.include_bitwarden_license_folder}} == "true" ]]; then
mkdir -p browser-source/bitwarden_license/bit-browser
cp -r bitwarden_license/bit-browser/* browser-source/bitwarden_license/bit-browser
fi
# Copy libs to Browser source directory
mkdir browser-source/libs
cp -r libs/* browser-source/libs
@@ -175,13 +190,13 @@ jobs:
- name: Upload browser source
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: browser-source-${{ env._BUILD_NUMBER }}.zip
name: ${{matrix.license_type.archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip
path: browser-source.zip
if-no-files-found: error
build:
name: Build
name: Build ${{ matrix.browser.name }} - ${{ matrix.license_type.readable }}
runs-on: ubuntu-22.04
needs:
- setup
@@ -192,25 +207,38 @@ jobs:
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
strategy:
matrix:
include:
license_type:
- build_prefix: ""
artifact_prefix: ""
source_archive_name_prefix: ""
archive_name_prefix: ""
npm_command_prefix: "dist:"
readable: "open source license"
- build_prefix: "bit-"
artifact_prefix: "bit-"
source_archive_name_prefix: "bit-"
archive_name_prefix: "bit-"
npm_command_prefix: "dist:bit:"
readable: "commercial license"
browser:
- name: "chrome"
npm_command: "dist:chrome"
npm_command_suffix: "chrome"
archive_name: "dist-chrome.zip"
artifact_name: "dist-chrome-MV3"
- name: "edge"
npm_command: "dist:edge"
npm_command_suffix: "edge"
archive_name: "dist-edge.zip"
artifact_name: "dist-edge-MV3"
- name: "firefox"
npm_command: "dist:firefox"
npm_command_suffix: "firefox"
archive_name: "dist-firefox.zip"
artifact_name: "dist-firefox"
- name: "firefox-mv3"
npm_command: "dist:firefox:mv3"
npm_command_suffix: "firefox:mv3"
archive_name: "dist-firefox.zip"
artifact_name: "DO-NOT-USE-FOR-PROD-dist-firefox-MV3"
- name: "opera-mv3"
npm_command: "dist:opera:mv3"
npm_command_suffix: "opera:mv3"
archive_name: "dist-opera.zip"
artifact_name: "dist-opera-MV3"
steps:
@@ -234,7 +262,7 @@ jobs:
- name: Download browser source
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: browser-source-${{ env._BUILD_NUMBER }}.zip
name: ${{matrix.license_type.source_archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip
- name: Unzip browser source artifact
run: |
@@ -264,7 +292,7 @@ jobs:
run: npm link ../sdk-internal
- name: Check source file size
if: ${{ startsWith(matrix.name, 'firefox') }}
if: ${{ startsWith(matrix.browser.name, 'firefox') }}
run: |
# Declare variable as indexed array
declare -a FILES
@@ -287,19 +315,19 @@ jobs:
fi
- name: Build extension
run: npm run ${{ matrix.npm_command }}
run: npm run ${{matrix.license_type.npm_command_prefix}}${{ matrix.browser.npm_command_suffix }}
working-directory: browser-source/apps/browser
- name: Upload extension artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: ${{ matrix.artifact_name }}-${{ env._BUILD_NUMBER }}.zip
path: browser-source/apps/browser/dist/${{ matrix.archive_name }}
name: ${{ matrix.license_type.artifact_prefix }}${{ matrix.browser.artifact_name }}-${{ env._BUILD_NUMBER }}.zip
path: browser-source/apps/browser/dist/${{matrix.license_type.archive_name_prefix}}${{ matrix.browser.archive_name }}
if-no-files-found: error
build-safari:
name: Build Safari
name: Build Safari - ${{ matrix.license_type.readable }}
runs-on: macos-13
permissions:
contents: read
@@ -308,6 +336,19 @@ jobs:
- setup
- locales-test
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
strategy:
matrix:
license_type:
- build_prefix: ""
artifact_prefix: ""
archive_name_prefix: ""
npm_command_prefix: "dist:"
readable: "open source license"
- build_prefix: "bit-"
artifact_prefix: "bit-"
archive_name_prefix: "bit-"
npm_command_prefix: "dist:bit:"
readable: "commercial license"
env:
_BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -433,21 +474,21 @@ jobs:
npm link ../sdk-internal
- name: Build Safari extension
run: npm run dist:safari
run: npm run ${{matrix.license_type.npm_command_prefix}}safari
working-directory: apps/browser
- name: Zip Safari build artifact
run: |
cd apps/browser/dist
zip dist-safari.zip ./Safari/**/build/Release/safari.appex -r
zip ${{matrix.license_type.archive_name_prefix }}dist-safari.zip ./Safari/**/build/Release/safari.appex -r
pwd
ls -la
- name: Upload Safari artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: dist-safari-${{ env._BUILD_NUMBER }}.zip
path: apps/browser/dist/dist-safari.zip
name: ${{matrix.license_type.archive_name_prefix}}dist-safari-${{ env._BUILD_NUMBER }}.zip
path: apps/browser/dist/${{matrix.license_type.archive_name_prefix}}dist-safari.zip
if-no-files-found: error
crowdin-push:

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,8 @@ const config: StorybookConfig = {
"../libs/auth/src/**/*.stories.@(js|jsx|ts|tsx)",
"../libs/dirt/card/src/**/*.mdx",
"../libs/dirt/card/src/**/*.stories.@(js|jsx|ts|tsx)",
"../libs/pricing/src/**/*.mdx",
"../libs/pricing/src/**/*.stories.@(js|jsx|ts|tsx)",
"../libs/tools/send/send-ui/src/**/*.mdx",
"../libs/tools/send/send-ui/src/**/*.stories.@(js|jsx|ts|tsx)",
"../libs/vault/src/**/*.mdx",

View File

@@ -3,32 +3,58 @@
"version": "2025.8.2",
"scripts": {
"build": "npm run build:chrome",
"build:bit": "npm run build:bit:chrome",
"build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
"build:bit:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js",
"build:edge": "cross-env BROWSER=edge MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
"build:bit:edge": "cross-env BROWSER=edge MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js",
"build:firefox": "cross-env BROWSER=firefox NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
"build:bit:firefox": "cross-env BROWSER=firefox NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js",
"build:opera": "cross-env BROWSER=opera MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
"build:bit:opera": "cross-env BROWSER=opera MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js",
"build:safari": "cross-env BROWSER=safari NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
"build:bit:safari": "cross-env BROWSER=safari NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js",
"build:watch": "npm run build:watch:chrome",
"build:watch:chrome": "npm run build:chrome -- --watch",
"build:bit:watch:chrome": "npm run build:bit:chrome -- --watch",
"build:watch:edge": "npm run build:edge -- --watch",
"build:bit:watch:edge": "npm run build:bit:edge -- --watch",
"build:watch:firefox": "npm run build:firefox -- --watch",
"build:bit:watch:firefox": "npm run build:bit:firefox -- --watch",
"build:watch:opera": "npm run build:opera -- --watch",
"build:bit:watch:opera": "npm run build:bit:opera -- --watch",
"build:watch:safari": "npm run build:safari -- --watch",
"build:watch:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run build:firefox -- --watch",
"build:watch:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run build:safari -- --watch",
"build:bit:watch:safari": "npm run build:bit:safari -- --watch",
"build:watch:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run build:watch:firefox",
"build:bit:watch:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run build:bit:watch:firefox",
"build:watch:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run build:watch:safari",
"build:bit:watch:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run build:bit:watch:safari",
"build:prod:chrome": "cross-env NODE_ENV=production npm run build:chrome",
"build:bit:prod:chrome": "cross-env NODE_ENV=production npm run build:bit:chrome",
"build:prod:edge": "cross-env NODE_ENV=production npm run build:edge",
"build:bit:prod:edge": "cross-env NODE_ENV=production npm run build:bit:edge",
"build:prod:firefox": "cross-env NODE_ENV=production npm run build:firefox",
"build:bit:prod:firefox": "cross-env NODE_ENV=production npm run build:bit:firefox",
"build:prod:opera": "cross-env NODE_ENV=production npm run build:opera",
"build:bit:prod:opera": "cross-env NODE_ENV=production npm run build:bit:opera",
"build:prod:safari": "cross-env NODE_ENV=production npm run build:safari",
"build:bit:prod:safari": "cross-env NODE_ENV=production npm run build:bit:safari",
"dist:chrome": "npm run build:prod:chrome && mkdir -p dist && ./scripts/compress.sh dist-chrome.zip",
"dist:bit:chrome": "npm run build:bit:prod:chrome && mkdir -p dist && ./scripts/compress.sh bit-dist-chrome.zip",
"dist:edge": "npm run build:prod:edge && mkdir -p dist && ./scripts/compress.sh dist-edge.zip",
"dist:bit:edge": "npm run build:bit:prod:edge && mkdir -p dist && ./scripts/compress.sh bit-dist-edge.zip",
"dist:firefox": "npm run build:prod:firefox && mkdir -p dist && ./scripts/compress.sh dist-firefox.zip",
"dist:bit:firefox": "npm run build:bit:prod:firefox && mkdir -p dist && ./scripts/compress.sh bit-dist-firefox.zip",
"dist:opera": "npm run build:prod:opera && mkdir -p dist && ./scripts/compress.sh dist-opera.zip",
"dist:bit:opera": "npm run build:bit:prod:opera && mkdir -p dist && ./scripts/compress.sh bit-dist-opera.zip",
"dist:safari": "npm run build:prod:safari && ./scripts/package-safari.ps1",
"dist:bit:safari": "npm run build:bit:prod:safari && ./scripts/package-safari.ps1",
"dist:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:firefox",
"dist:bit:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:bit:firefox",
"dist:opera:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:opera",
"dist:bit:opera:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:bit:opera",
"dist:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:safari",
"dist:bit:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:bit:safari",
"test": "jest",
"test:watch": "jest --watch",
"test:watch:all": "jest --watchAll",

View File

@@ -5588,8 +5588,14 @@
"showLess": {
"message": "Show less"
},
"next": {
"message": "Next"
},
"moreBreadcrumbs": {
"message": "More breadcrumbs",
"description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed."
},
"confirmKeyConnectorDomain": {
"message": "Confirm Key Connector domain"
}
}

View File

@@ -158,10 +158,13 @@ import { ApiService } from "@bitwarden/common/services/api.service";
import { AuditService } from "@bitwarden/common/services/audit.service";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider";
import { buildExtensionRegistry } from "@bitwarden/common/tools/extension/factory";
import {
PasswordStrengthService,
PasswordStrengthServiceAbstraction,
} from "@bitwarden/common/tools/password-strength";
import { createSystemServiceProvider } from "@bitwarden/common/tools/providers";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service";
import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider";
@@ -302,6 +305,7 @@ import { OffscreenStorageService } from "../platform/storage/offscreen-storage.s
import { SyncServiceListener } from "../platform/sync/sync-service.listener";
import { BrowserSystemNotificationService } from "../platform/system-notifications/browser-system-notification.service";
import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging";
import { AtRiskCipherBadgeUpdaterService } from "../vault/services/at-risk-cipher-badge-updater.service";
import CommandsBackground from "./commands.background";
import IdleBackground from "./idle.background";
@@ -433,6 +437,7 @@ export default class MainBackground {
badgeService: BadgeService;
authStatusBadgeUpdaterService: AuthStatusBadgeUpdaterService;
autofillBadgeUpdaterService: AutofillBadgeUpdaterService;
atRiskCipherUpdaterService: AtRiskCipherBadgeUpdaterService;
onUpdatedRan: boolean;
onReplacedRan: boolean;
@@ -735,6 +740,7 @@ export default class MainBackground {
this.logService,
(logoutReason: LogoutReason, userId?: UserId) => this.logout(logoutReason, userId),
this.vaultTimeoutSettingsService,
this.accountService,
{ createRequest: (url, request) => new Request(url, request) },
);
@@ -783,6 +789,7 @@ export default class MainBackground {
this.kdfConfigService,
this.keyService,
this.stateProvider,
this.configService,
);
this.passwordStrengthService = new PasswordStrengthService();
@@ -841,7 +848,7 @@ export default class MainBackground {
this.tokenService,
);
this.configApiService = new ConfigApiService(this.apiService, this.tokenService);
this.configApiService = new ConfigApiService(this.apiService);
this.configService = new DefaultConfigService(
this.configApiService,
@@ -1052,8 +1059,16 @@ export default class MainBackground {
this.encryptService,
this.pinService,
this.accountService,
this.sdkService,
this.restrictedItemTypesService,
createSystemServiceProvider(
new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService),
this.stateProvider,
this.policyService,
buildExtensionRegistry(),
this.logService,
this.platformUtilsService,
this.configService,
),
);
this.individualVaultExportService = new IndividualVaultExportService(
@@ -1838,6 +1853,14 @@ export default class MainBackground {
this.logService,
);
this.atRiskCipherUpdaterService = new AtRiskCipherBadgeUpdaterService(
this.badgeService,
this.accountService,
this.cipherService,
this.logService,
this.taskService,
);
this.tabsBackground = new TabsBackground(
this,
this.notificationBackground,
@@ -1847,6 +1870,7 @@ export default class MainBackground {
await this.overlayBackground.init();
await this.tabsBackground.init();
await this.autofillBadgeUpdaterService.init();
await this.atRiskCipherUpdaterService.init();
}
generatePassword = async (): Promise<string> => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,4 +1,8 @@
export const BadgeIcon = {
Berry: {
19: "/images/berry19.png",
38: "/images/berry38.png",
},
LoggedOut: {
19: "/images/icon19_gray.png",
38: "/images/icon38_gray.png",

View File

@@ -42,7 +42,7 @@ import {
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AnonLayoutWrapperData } from "@bitwarden/components";
import { LockComponent } from "@bitwarden/key-management-ui";
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component";
import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard";
@@ -598,6 +598,24 @@ const routes: Routes = [
},
],
},
{
path: "confirm-key-connector-domain",
component: ExtensionAnonLayoutWrapperComponent,
canActivate: [],
data: { elevation: 1 } satisfies RouteDataProperties,
children: [
{
path: "",
component: ConfirmKeyConnectorDomainComponent,
data: {
pageTitle: {
key: "confirmKeyConnectorDomain",
},
showBackButton: true,
} satisfies ExtensionAnonLayoutWrapperData,
},
],
},
{
path: "tabs",
component: TabsV2Component,

View File

@@ -0,0 +1,20 @@
@if (showSdkWarning | async) {
<div class="tw-h-screen tw-flex tw-justify-center tw-items-center tw-p-4">
<bit-callout type="danger">
{{ "wasmNotSupported" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/wasm-not-supported/"
target="_blank"
rel="noreferrer"
>
{{ "learnMore" | i18n }}
</a>
</bit-callout>
</div>
} @else {
<div [@routerTransition]="getRouteElevation(outlet)">
<router-outlet #outlet="outlet"></router-outlet>
</div>
<bit-toast-container></bit-toast-container>
}

View File

@@ -57,28 +57,7 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn
selector: "app-root",
styles: [],
animations: [routerTransition],
template: `
@if (showSdkWarning | async) {
<div class="tw-h-screen tw-flex tw-justify-center tw-items-center tw-p-4">
<bit-callout type="danger">
{{ "wasmNotSupported" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/wasm-not-supported/"
target="_blank"
rel="noreferrer"
>
{{ "learnMore" | i18n }}
</a>
</bit-callout>
</div>
} @else {
<div [@routerTransition]="getRouteElevation(outlet)">
<router-outlet #outlet="outlet"></router-outlet>
</div>
<bit-toast-container></bit-toast-container>
}
`,
templateUrl: "app.component.html",
standalone: false,
})
export class AppComponent implements OnInit, OnDestroy {

View File

@@ -4,13 +4,10 @@ import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { PopupSizeService } from "../platform/popup/layout/popup-size.service";
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./scss/popup.scss");
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./scss/tailwind.css");
import { AppModule } from "./app.module";
import "./scss";
// We put these first to minimize the delay in window changing.
PopupSizeService.initBodyWidthFromLocalStorage();
// Should be removed once we deprecate support for Safari 16.0 and older. See Jira ticket [PM-1861]

View File

@@ -0,0 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./popup.scss");
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./tailwind.css");

View File

@@ -43,11 +43,13 @@ import {
AccountService,
AccountService as AccountServiceAbstraction,
} from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service";
import {
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
@@ -66,7 +68,10 @@ import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import {
InternalMasterPasswordServiceAbstraction,
MasterPasswordServiceAbstraction,
} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import {
VaultTimeoutService,
@@ -466,6 +471,19 @@ const safeProviders: SafeProvider[] = [
useClass: InlineDerivedStateProvider,
deps: [],
}),
safeProvider({
provide: AuthRequestAnsweringServiceAbstraction,
useClass: AuthRequestAnsweringService,
deps: [
AccountServiceAbstraction,
ActionsService,
AuthService,
I18nServiceAbstraction,
MasterPasswordServiceAbstraction,
PlatformUtilsService,
SystemNotificationsService,
],
}),
safeProvider({
provide: AutofillSettingsServiceAbstraction,
useClass: AutofillSettingsService,

View File

@@ -0,0 +1,84 @@
import { BehaviorSubject } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SecurityTask, TaskService } from "@bitwarden/common/vault/tasks";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import { BadgeService } from "../../platform/badge/badge.service";
import { BadgeIcon } from "../../platform/badge/icon";
import { BadgeStatePriority } from "../../platform/badge/priority";
import { Unset } from "../../platform/badge/state";
import { BrowserApi } from "../../platform/browser/browser-api";
import { AtRiskCipherBadgeUpdaterService } from "./at-risk-cipher-badge-updater.service";
describe("AtRiskCipherBadgeUpdaterService", () => {
let service: AtRiskCipherBadgeUpdaterService;
let setState: jest.Mock;
let clearState: jest.Mock;
let warning: jest.Mock;
let getAllDecryptedForUrl: jest.Mock;
let getTab: jest.Mock;
let addListener: jest.Mock;
const activeAccount$ = new BehaviorSubject({ id: "test-account-id" });
const cipherViews$ = new BehaviorSubject([]);
const pendingTasks$ = new BehaviorSubject<SecurityTask[]>([]);
const userId = "test-user-id" as UserId;
beforeEach(async () => {
setState = jest.fn().mockResolvedValue(undefined);
clearState = jest.fn().mockResolvedValue(undefined);
warning = jest.fn();
getAllDecryptedForUrl = jest.fn().mockResolvedValue([]);
getTab = jest.fn();
addListener = jest.fn();
jest.spyOn(BrowserApi, "addListener").mockImplementation(addListener);
jest.spyOn(BrowserApi, "getTab").mockImplementation(getTab);
service = new AtRiskCipherBadgeUpdaterService(
{ setState, clearState } as unknown as BadgeService,
{ activeAccount$ } as unknown as AccountService,
{ cipherViews$, getAllDecryptedForUrl } as unknown as CipherService,
{ warning } as unknown as LogService,
{ pendingTasks$ } as unknown as TaskService,
);
await service.init();
});
afterEach(() => {
jest.restoreAllMocks();
});
it("clears the tab state when there are no ciphers and no pending tasks", async () => {
const tab = { id: 1 } as chrome.tabs.Tab;
await service["setTabState"](tab, userId, []);
expect(clearState).toHaveBeenCalledWith("at-risk-cipher-badge-1");
});
it("sets state when there are pending tasks for the tab", async () => {
const tab = { id: 3, url: "https://bitwarden.com" } as chrome.tabs.Tab;
const pendingTasks: SecurityTask[] = [{ id: "task1", cipherId: "cipher1" } as SecurityTask];
getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]);
await service["setTabState"](tab, userId, pendingTasks);
expect(setState).toHaveBeenCalledWith(
"at-risk-cipher-badge-3",
BadgeStatePriority.High,
{
icon: BadgeIcon.Berry,
text: Unset,
backgroundColor: Unset,
},
3,
);
});
});

View File

@@ -0,0 +1,163 @@
import { combineLatest, map, mergeMap, of, Subject, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { BadgeService } from "../../platform/badge/badge.service";
import { BadgeIcon } from "../../platform/badge/icon";
import { BadgeStatePriority } from "../../platform/badge/priority";
import { Unset } from "../../platform/badge/state";
import { BrowserApi } from "../../platform/browser/browser-api";
const StateName = (tabId: number) => `at-risk-cipher-badge-${tabId}`;
export class AtRiskCipherBadgeUpdaterService {
private tabReplaced$ = new Subject<{ addedTab: chrome.tabs.Tab; removedTabId: number }>();
private tabUpdated$ = new Subject<chrome.tabs.Tab>();
private tabRemoved$ = new Subject<number>();
private tabActivated$ = new Subject<chrome.tabs.Tab>();
private activeUserData$ = this.accountService.activeAccount$.pipe(
filterOutNullish(),
switchMap((user) =>
combineLatest([
of(user.id),
this.taskService
.pendingTasks$(user.id)
.pipe(
map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)),
),
this.cipherService.cipherViews$(user.id).pipe(filterOutNullish()),
]),
),
);
constructor(
private badgeService: BadgeService,
private accountService: AccountService,
private cipherService: CipherService,
private logService: LogService,
private taskService: TaskService,
) {
combineLatest({
replaced: this.tabReplaced$,
activeUserData: this.activeUserData$,
})
.pipe(
mergeMap(async ({ replaced, activeUserData: [userId, pendingTasks] }) => {
await this.clearTabState(replaced.removedTabId);
await this.setTabState(replaced.addedTab, userId, pendingTasks);
}),
)
.subscribe(() => {});
combineLatest({
tab: this.tabActivated$,
activeUserData: this.activeUserData$,
})
.pipe(
mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => {
await this.setTabState(tab, userId, pendingTasks);
}),
)
.subscribe();
combineLatest({
tab: this.tabUpdated$,
activeUserData: this.activeUserData$,
})
.pipe(
mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => {
await this.setTabState(tab, userId, pendingTasks);
}),
)
.subscribe();
this.tabRemoved$
.pipe(
mergeMap(async (tabId) => {
await this.clearTabState(tabId);
}),
)
.subscribe();
}
init() {
BrowserApi.addListener(chrome.tabs.onReplaced, async (addedTabId, removedTabId) => {
const newTab = await BrowserApi.getTab(addedTabId);
if (!newTab) {
this.logService.warning(
`Tab replaced event received but new tab not found (id: ${addedTabId})`,
);
return;
}
this.tabReplaced$.next({
removedTabId,
addedTab: newTab,
});
});
BrowserApi.addListener(chrome.tabs.onUpdated, (_, changeInfo, tab) => {
if (changeInfo.url) {
this.tabUpdated$.next(tab);
}
});
BrowserApi.addListener(chrome.tabs.onActivated, async (activeInfo) => {
const tab = await BrowserApi.getTab(activeInfo.tabId);
if (!tab) {
this.logService.warning(
`Tab activated event received but tab not found (id: ${activeInfo.tabId})`,
);
return;
}
this.tabActivated$.next(tab);
});
BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId));
}
/** Sets the pending task state for the tab */
private async setTabState(tab: chrome.tabs.Tab, userId: UserId, pendingTasks: SecurityTask[]) {
if (!tab.id) {
this.logService.warning("Tab event received but tab id is undefined");
return;
}
const ciphers = tab.url
? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true)
: [];
const hasPendingTasksForTab = pendingTasks.some((task) =>
ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted),
);
if (!hasPendingTasksForTab) {
await this.clearTabState(tab.id);
return;
}
await this.badgeService.setState(
StateName(tab.id),
BadgeStatePriority.High,
{
icon: BadgeIcon.Berry,
// Unset text and background color to use default badge appearance
text: Unset,
backgroundColor: Unset,
},
tab.id,
);
}
/** Clears the pending task state from a tab */
private async clearTabState(tabId: number) {
await this.badgeService.clearState(StateName(tabId));
}
}

View File

@@ -0,0 +1,443 @@
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const { AngularWebpackPlugin } = require("@ngtools/webpack");
const TerserPlugin = require("terser-webpack-plugin");
const { TsconfigPathsPlugin } = require("tsconfig-paths-webpack-plugin");
const configurator = require("./config/config");
const manifest = require("./webpack/manifest");
const AngularCheckPlugin = require("./webpack/angular-check");
module.exports.getEnv = function getEnv() {
const ENV = (process.env.ENV = process.env.NODE_ENV);
const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2;
const browser = process.env.BROWSER ?? "chrome";
return { ENV, manifestVersion, browser };
};
/**
* @param {{
* configName: string;
* popup: {
* entry: string;
* entryModule: string;
* };
* background: {
* entry: string;
* };
* tsConfig: string;
* additionalEntries?: { [outputPath: string]: string }
* }} params - The input parameters for building the config.
*/
module.exports.buildConfig = function buildConfig(params) {
if (process.env.NODE_ENV == null) {
process.env.NODE_ENV = "development";
}
const { ENV, manifestVersion, browser } = module.exports.getEnv();
console.log(`Building Manifest Version ${manifestVersion} app - ${params.configName} version`);
const envConfig = configurator.load(ENV);
configurator.log(envConfig);
const moduleRules = [
{
test: /\.(html)$/,
loader: "html-loader",
},
{
test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/,
exclude: /loading.svg/,
generator: {
filename: "popup/fonts/[name].[contenthash][ext]",
},
type: "asset/resource",
},
{
test: /\.(jpe?g|png|gif|svg)$/i,
exclude: /.*(bwi-font|glyphicons-halflings-regular)\.svg/,
generator: {
filename: "popup/images/[name][ext]",
},
type: "asset/resource",
},
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
"css-loader",
"resolve-url-loader",
{
loader: "postcss-loader",
options: {
sourceMap: true,
},
},
],
},
{
test: /\.scss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
"css-loader",
"resolve-url-loader",
{
loader: "sass-loader",
options: {
sourceMap: true,
},
},
],
},
{
test: /\.[cm]?js$/,
use: [
{
loader: "babel-loader",
options: {
configFile: "../../babel.config.json",
cacheDirectory: ENV === "development",
compact: ENV !== "development",
},
},
],
},
{
test: /\.[jt]sx?$/,
loader: "@ngtools/webpack",
},
];
const requiredPlugins = [
new webpack.DefinePlugin({
"process.env": {
ENV: JSON.stringify(ENV),
},
}),
new webpack.EnvironmentPlugin({
FLAGS: envConfig.flags,
DEV_FLAGS: ENV === "development" ? envConfig.devFlags : {},
}),
];
const plugins = [
new HtmlWebpackPlugin({
template: "./src/popup/index.ejs",
filename: "popup/index.html",
chunks: ["popup/polyfills", "popup/vendor-angular", "popup/vendor", "popup/main"],
browser: browser,
}),
new HtmlWebpackPlugin({
template: "./src/autofill/notification/bar.html",
filename: "notification/bar.html",
chunks: ["notification/bar"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/overlay/inline-menu/pages/button/button.html",
filename: "overlay/menu-button.html",
chunks: ["overlay/menu-button"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/overlay/inline-menu/pages/list/list.html",
filename: "overlay/menu-list.html",
chunks: ["overlay/menu-list"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html",
filename: "overlay/menu.html",
chunks: ["overlay/menu"],
}),
new CopyWebpackPlugin({
patterns: [
{
from: manifestVersion == 3 ? "./src/manifest.v3.json" : "./src/manifest.json",
to: "manifest.json",
transform: manifest.transform(browser),
},
{ from: "./src/managed_schema.json", to: "managed_schema.json" },
{ from: "./src/_locales", to: "_locales" },
{ from: "./src/images", to: "images" },
{ from: "./src/popup/images", to: "popup/images" },
{ from: "./src/autofill/content/autofill.css", to: "content" },
],
}),
new MiniCssExtractPlugin({
filename: "[name].css",
chunkFilename: "chunk-[id].css",
}),
new AngularWebpackPlugin({
tsconfig: params.tsConfig,
entryModule: params.popup.entryModule,
sourceMap: true,
}),
new webpack.ProvidePlugin({
process: "process/browser.js",
}),
new webpack.SourceMapDevToolPlugin({
exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/],
filename: "[file].map",
}),
...requiredPlugins,
];
/**
* @type {import("webpack").Configuration}
* This config compiles everything but the background
*/
const mainConfig = {
name: "main",
mode: ENV,
devtool: false,
entry: {
"popup/polyfills": "./src/popup/polyfills.ts",
"popup/main": params.popup.entry,
"content/trigger-autofill-script-injection":
"./src/autofill/content/trigger-autofill-script-injection.ts",
"content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts",
"content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts",
"content/bootstrap-autofill-overlay-menu":
"./src/autofill/content/bootstrap-autofill-overlay-menu.ts",
"content/bootstrap-autofill-overlay-notifications":
"./src/autofill/content/bootstrap-autofill-overlay-notifications.ts",
"content/autofiller": "./src/autofill/content/autofiller.ts",
"content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts",
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
"content/content-message-handler": "./src/autofill/content/content-message-handler.ts",
"content/fido2-content-script": "./src/autofill/fido2/content/fido2-content-script.ts",
"content/fido2-page-script": "./src/autofill/fido2/content/fido2-page-script.ts",
"content/ipc-content-script": "./src/platform/ipc/content/ipc-content-script.ts",
"notification/bar": "./src/autofill/notification/bar.ts",
"overlay/menu-button":
"./src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts",
"overlay/menu-list":
"./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts",
"overlay/menu":
"./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts",
"content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts",
"content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts",
...params.additionalEntries,
},
cache:
ENV !== "development"
? false
: {
type: "filesystem",
name: "main-cache",
cacheDirectory: path.resolve(
__dirname,
"../../node_modules/.cache/webpack-browser-main",
),
buildDependencies: {
config: [__filename],
},
},
snapshot: {
unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")],
},
optimization: {
minimize: ENV !== "development",
minimizer: [
new TerserPlugin({
exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/],
terserOptions: {
// Replicate Angular CLI behaviour
compress: {
global_defs: {
ngDevMode: false,
ngI18nClosureMode: false,
},
},
},
}),
],
splitChunks: {
cacheGroups: {
commons: {
test(module, chunks) {
return (
module.resource != null &&
module.resource.includes(`${path.sep}node_modules${path.sep}`) &&
!module.resource.includes(`${path.sep}node_modules${path.sep}@angular${path.sep}`)
);
},
name: "popup/vendor",
chunks: (chunk) => {
return chunk.name === "popup/main";
},
},
angular: {
test(module, chunks) {
return (
module.resource != null &&
module.resource.includes(`${path.sep}node_modules${path.sep}@angular${path.sep}`)
);
},
name: "popup/vendor-angular",
chunks: (chunk) => {
return chunk.name === "popup/main";
},
},
},
},
},
resolve: {
extensions: [".ts", ".js"],
symlinks: false,
modules: [path.resolve("../../node_modules")],
fallback: {
assert: false,
buffer: require.resolve("buffer/"),
util: require.resolve("util/"),
url: require.resolve("url/"),
fs: false,
path: require.resolve("path-browserify"),
},
cache: true,
},
output: {
filename: "[name].js",
chunkFilename: "assets/[name].js",
webassemblyModuleFilename: "assets/[modulehash].wasm",
path: path.resolve(__dirname, "build"),
clean: true,
},
module: {
rules: moduleRules,
},
experiments: {
asyncWebAssembly: true,
},
plugins: plugins,
};
/**
* @type {import("webpack").Configuration[]}
*/
const configs = [];
if (manifestVersion == 2) {
mainConfig.optimization.splitChunks.cacheGroups.commons2 = {
test: /[\\/]node_modules[\\/]/,
name: "vendor",
chunks: (chunk) => {
return chunk.name === "background";
},
};
// Manifest V2 uses Background Pages which requires a html page.
mainConfig.plugins.push(
new HtmlWebpackPlugin({
template: "./src/platform/background.html",
filename: "background.html",
chunks: ["vendor", "background"],
}),
);
// Manifest V2 background pages can be run through the regular build pipeline.
// Since it's a standard webpage.
mainConfig.entry.background = params.background.entry;
mainConfig.entry["content/fido2-page-script-delay-append-mv2"] =
"./src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts";
configs.push(mainConfig);
} else {
// Firefox does not use the offscreen API
if (browser !== "firefox") {
mainConfig.entry["offscreen-document/offscreen-document"] =
"./src/platform/offscreen-document/offscreen-document.ts";
mainConfig.plugins.push(
new HtmlWebpackPlugin({
template: "./src/platform/offscreen-document/index.html",
filename: "offscreen-document/index.html",
chunks: ["offscreen-document/offscreen-document"],
}),
);
}
const target = browser === "firefox" ? "web" : "webworker";
/**
* @type {import("webpack").Configuration}
*/
const backgroundConfig = {
name: "background",
mode: ENV,
devtool: false,
entry: params.background.entry,
target: target,
output: {
filename: "background.js",
path: path.resolve(__dirname, "build"),
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: "ts-loader",
},
],
},
cache:
ENV !== "development"
? false
: {
type: "filesystem",
name: "background-cache",
cacheDirectory: path.resolve(
__dirname,
"../../node_modules/.cache/webpack-browser-background",
),
buildDependencies: {
config: [__filename],
},
},
snapshot: {
unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")],
},
experiments: {
asyncWebAssembly: true,
},
resolve: {
extensions: [".ts", ".js"],
symlinks: false,
modules: [path.resolve("../../node_modules")],
plugins: [new TsconfigPathsPlugin()],
fallback: {
fs: false,
path: require.resolve("path-browserify"),
},
cache: true,
},
dependencies: ["main"],
plugins: [...requiredPlugins, new AngularCheckPlugin()],
};
// Safari's desktop build process requires a background.html and vendor.js file to exist
// within the root of the extension. This is a workaround to allow us to build Safari
// for manifest v2 and v3 without modifying the desktop project structure.
if (browser === "safari") {
backgroundConfig.plugins.push(
new CopyWebpackPlugin({
patterns: [
{ from: "./src/safari/mv3/fake-background.html", to: "background.html" },
{ from: "./src/safari/mv3/fake-vendor.js", to: "vendor.js" },
],
}),
);
}
configs.push(mainConfig);
configs.push(backgroundConfig);
}
return configs;
};

View File

@@ -1,416 +1,13 @@
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const { AngularWebpackPlugin } = require("@ngtools/webpack");
const TerserPlugin = require("terser-webpack-plugin");
const { TsconfigPathsPlugin } = require("tsconfig-paths-webpack-plugin");
const configurator = require("./config/config");
const manifest = require("./webpack/manifest");
const AngularCheckPlugin = require("./webpack/angular-check");
const { buildConfig } = require("./webpack.base");
if (process.env.NODE_ENV == null) {
process.env.NODE_ENV = "development";
}
const ENV = (process.env.ENV = process.env.NODE_ENV);
const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2;
const browser = process.env.BROWSER ?? "chrome";
console.log(`Building Manifest Version ${manifestVersion} app`);
const envConfig = configurator.load(ENV);
configurator.log(envConfig);
const moduleRules = [
{
test: /\.(html)$/,
loader: "html-loader",
},
{
test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/,
exclude: /loading.svg/,
generator: {
filename: "popup/fonts/[name].[contenthash][ext]",
},
type: "asset/resource",
},
{
test: /\.(jpe?g|png|gif|svg)$/i,
exclude: /.*(bwi-font|glyphicons-halflings-regular)\.svg/,
generator: {
filename: "popup/images/[name][ext]",
},
type: "asset/resource",
},
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
"css-loader",
"resolve-url-loader",
{
loader: "postcss-loader",
options: {
sourceMap: true,
},
},
],
},
{
test: /\.scss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
"css-loader",
"resolve-url-loader",
{
loader: "sass-loader",
options: {
sourceMap: true,
},
},
],
},
{
test: /\.[cm]?js$/,
use: [
{
loader: "babel-loader",
options: {
configFile: "../../babel.config.json",
cacheDirectory: ENV === "development",
compact: ENV !== "development",
},
},
],
},
{
test: /\.[jt]sx?$/,
loader: "@ngtools/webpack",
},
];
const requiredPlugins = [
new webpack.DefinePlugin({
"process.env": {
ENV: JSON.stringify(ENV),
},
}),
new webpack.EnvironmentPlugin({
FLAGS: envConfig.flags,
DEV_FLAGS: ENV === "development" ? envConfig.devFlags : {},
}),
];
const plugins = [
new HtmlWebpackPlugin({
template: "./src/popup/index.ejs",
filename: "popup/index.html",
chunks: ["popup/polyfills", "popup/vendor-angular", "popup/vendor", "popup/main"],
browser: browser,
}),
new HtmlWebpackPlugin({
template: "./src/autofill/notification/bar.html",
filename: "notification/bar.html",
chunks: ["notification/bar"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/overlay/inline-menu/pages/button/button.html",
filename: "overlay/menu-button.html",
chunks: ["overlay/menu-button"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/overlay/inline-menu/pages/list/list.html",
filename: "overlay/menu-list.html",
chunks: ["overlay/menu-list"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html",
filename: "overlay/menu.html",
chunks: ["overlay/menu"],
}),
new CopyWebpackPlugin({
patterns: [
{
from: manifestVersion == 3 ? "./src/manifest.v3.json" : "./src/manifest.json",
to: "manifest.json",
transform: manifest.transform(browser),
},
{ from: "./src/managed_schema.json", to: "managed_schema.json" },
{ from: "./src/_locales", to: "_locales" },
{ from: "./src/images", to: "images" },
{ from: "./src/popup/images", to: "popup/images" },
{ from: "./src/autofill/content/autofill.css", to: "content" },
],
}),
new MiniCssExtractPlugin({
filename: "[name].css",
chunkFilename: "chunk-[id].css",
}),
new AngularWebpackPlugin({
tsConfigPath: "tsconfig.json",
module.exports = buildConfig({
configName: "OSS",
popup: {
entry: "./src/popup/main.ts",
entryModule: "src/popup/app.module#AppModule",
sourceMap: true,
}),
new webpack.ProvidePlugin({
process: "process/browser.js",
}),
new webpack.SourceMapDevToolPlugin({
exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/],
filename: "[file].map",
}),
...requiredPlugins,
];
/**
* @type {import("webpack").Configuration}
* This config compiles everything but the background
*/
const mainConfig = {
name: "main",
mode: ENV,
devtool: false,
entry: {
"popup/polyfills": "./src/popup/polyfills.ts",
"popup/main": "./src/popup/main.ts",
"content/trigger-autofill-script-injection":
"./src/autofill/content/trigger-autofill-script-injection.ts",
"content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts",
"content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts",
"content/bootstrap-autofill-overlay-menu":
"./src/autofill/content/bootstrap-autofill-overlay-menu.ts",
"content/bootstrap-autofill-overlay-notifications":
"./src/autofill/content/bootstrap-autofill-overlay-notifications.ts",
"content/autofiller": "./src/autofill/content/autofiller.ts",
"content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts",
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
"content/content-message-handler": "./src/autofill/content/content-message-handler.ts",
"content/fido2-content-script": "./src/autofill/fido2/content/fido2-content-script.ts",
"content/fido2-page-script": "./src/autofill/fido2/content/fido2-page-script.ts",
"content/ipc-content-script": "./src/platform/ipc/content/ipc-content-script.ts",
"notification/bar": "./src/autofill/notification/bar.ts",
"overlay/menu-button":
"./src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts",
"overlay/menu-list":
"./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts",
"overlay/menu":
"./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts",
"content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts",
"content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts",
},
cache:
ENV !== "development"
? false
: {
type: "filesystem",
name: "main-cache",
cacheDirectory: path.resolve(__dirname, "../../node_modules/.cache/webpack-browser-main"),
buildDependencies: {
config: [__filename],
},
},
snapshot: {
unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")],
},
optimization: {
minimize: ENV !== "development",
minimizer: [
new TerserPlugin({
exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/],
terserOptions: {
// Replicate Angular CLI behaviour
compress: {
global_defs: {
ngDevMode: false,
ngI18nClosureMode: false,
},
},
},
}),
],
splitChunks: {
cacheGroups: {
commons: {
test(module, chunks) {
return (
module.resource != null &&
module.resource.includes(`${path.sep}node_modules${path.sep}`) &&
!module.resource.includes(`${path.sep}node_modules${path.sep}@angular${path.sep}`)
);
},
name: "popup/vendor",
chunks: (chunk) => {
return chunk.name === "popup/main";
},
},
angular: {
test(module, chunks) {
return (
module.resource != null &&
module.resource.includes(`${path.sep}node_modules${path.sep}@angular${path.sep}`)
);
},
name: "popup/vendor-angular",
chunks: (chunk) => {
return chunk.name === "popup/main";
},
},
},
},
},
resolve: {
extensions: [".ts", ".js"],
symlinks: false,
modules: [path.resolve("../../node_modules")],
fallback: {
assert: false,
buffer: require.resolve("buffer/"),
util: require.resolve("util/"),
url: require.resolve("url/"),
fs: false,
path: require.resolve("path-browserify"),
},
cache: true,
},
output: {
filename: "[name].js",
chunkFilename: "assets/[name].js",
webassemblyModuleFilename: "assets/[modulehash].wasm",
path: path.resolve(__dirname, "build"),
clean: true,
},
module: {
rules: moduleRules,
},
experiments: {
asyncWebAssembly: true,
},
plugins: plugins,
};
/**
* @type {import("webpack").Configuration[]}
*/
const configs = [];
if (manifestVersion == 2) {
mainConfig.optimization.splitChunks.cacheGroups.commons2 = {
test: /[\\/]node_modules[\\/]/,
name: "vendor",
chunks: (chunk) => {
return chunk.name === "background";
},
};
// Manifest V2 uses Background Pages which requires a html page.
mainConfig.plugins.push(
new HtmlWebpackPlugin({
template: "./src/platform/background.html",
filename: "background.html",
chunks: ["vendor", "background"],
}),
);
// Manifest V2 background pages can be run through the regular build pipeline.
// Since it's a standard webpage.
mainConfig.entry.background = "./src/platform/background.ts";
mainConfig.entry["content/fido2-page-script-delay-append-mv2"] =
"./src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts";
configs.push(mainConfig);
} else {
// Firefox does not use the offscreen API
if (browser !== "firefox") {
mainConfig.entry["offscreen-document/offscreen-document"] =
"./src/platform/offscreen-document/offscreen-document.ts";
mainConfig.plugins.push(
new HtmlWebpackPlugin({
template: "./src/platform/offscreen-document/index.html",
filename: "offscreen-document/index.html",
chunks: ["offscreen-document/offscreen-document"],
}),
);
}
const target = browser === "firefox" ? "web" : "webworker";
/**
* @type {import("webpack").Configuration}
*/
const backgroundConfig = {
name: "background",
mode: ENV,
devtool: false,
background: {
entry: "./src/platform/background.ts",
target: target,
output: {
filename: "background.js",
path: path.resolve(__dirname, "build"),
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: "ts-loader",
},
],
},
cache:
ENV !== "development"
? false
: {
type: "filesystem",
name: "background-cache",
cacheDirectory: path.resolve(
__dirname,
"../../node_modules/.cache/webpack-browser-background",
),
buildDependencies: {
config: [__filename],
},
},
snapshot: {
unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")],
},
experiments: {
asyncWebAssembly: true,
},
resolve: {
extensions: [".ts", ".js"],
symlinks: false,
modules: [path.resolve("../../node_modules")],
plugins: [new TsconfigPathsPlugin()],
fallback: {
fs: false,
path: require.resolve("path-browserify"),
},
cache: true,
},
dependencies: ["main"],
plugins: [...requiredPlugins, new AngularCheckPlugin()],
};
// Safari's desktop build process requires a background.html and vendor.js file to exist
// within the root of the extension. This is a workaround to allow us to build Safari
// for manifest v2 and v3 without modifying the desktop project structure.
if (browser === "safari") {
backgroundConfig.plugins.push(
new CopyWebpackPlugin({
patterns: [
{ from: "./src/safari/mv3/fake-background.html", to: "background.html" },
{ from: "./src/safari/mv3/fake-vendor.js", to: "vendor.js" },
],
}),
);
}
configs.push(mainConfig);
configs.push(backgroundConfig);
}
module.exports = configs;
},
tsConfig: "tsconfig.json",
});

View File

@@ -46,6 +46,7 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { NodeUtils } from "@bitwarden/node/node-utils";
import { ConfirmKeyConnectorDomainCommand } from "../../key-management/confirm-key-connector-domain.command";
import { Response } from "../../models/response";
import { MessageResponse } from "../../models/response/message.response";
@@ -332,6 +333,24 @@ export class LoginCommand {
);
}
// Check if Key Connector domain confirmation is required
const domainConfirmation = await firstValueFrom(
this.keyConnectorService.requiresDomainConfirmation$(response.userId),
);
if (domainConfirmation != null) {
const command = new ConfirmKeyConnectorDomainCommand(
response.userId,
domainConfirmation.keyConnectorUrl,
this.keyConnectorService,
this.logoutCallback,
this.i18nService,
);
const confirmResponse = await command.run();
if (!confirmResponse.success) {
return confirmResponse;
}
}
// Run full sync before handling success response or password reset flows (to get Master Password Policies)
await this.syncService.fullSync(true, { skipTokenRefresh: true });

View File

@@ -0,0 +1,153 @@
import { createPromptModule } from "inquirer";
import { mock } from "jest-mock-extended";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { UserId } from "@bitwarden/common/types/guid";
import { Response } from "../models/response";
import { MessageResponse } from "../models/response/message.response";
import { I18nService } from "../platform/services/i18n.service";
import { ConfirmKeyConnectorDomainCommand } from "./confirm-key-connector-domain.command";
jest.mock("inquirer", () => {
return {
createPromptModule: jest.fn(() => jest.fn(() => Promise.resolve({ confirm: "" }))),
};
});
describe("ConfirmKeyConnectorDomainCommand", () => {
let command: ConfirmKeyConnectorDomainCommand;
const userId = "test-user-id" as UserId;
const keyConnectorUrl = "https://keyconnector.example.com";
const keyConnectorService = mock<KeyConnectorService>();
const logout = jest.fn();
const i18nService = mock<I18nService>();
beforeEach(async () => {
command = new ConfirmKeyConnectorDomainCommand(
userId,
keyConnectorUrl,
keyConnectorService,
logout,
i18nService,
);
i18nService.t.mockImplementation((key: string) => {
switch (key) {
case "confirmKeyConnectorDomain":
return "Please confirm the domain below with your organization administrator. Key Connector domain: https://keyconnector.example.com";
case "confirm":
return "Confirm";
case "logOut":
return "Log out";
case "youHaveBeenLoggedOut":
return "You have been logged out.";
case "organizationUsingKeyConnectorConfirmLoggedOut":
return "An organization you are a member of is using Key Connector. In order to access the vault, you must confirm the Key Connector domain now via the web vault. You have been logged out.";
default:
return "";
}
});
});
describe("run", () => {
it("should logout and return error response if no interaction available", async () => {
process.env.BW_NOINTERACTION = "true";
const response = await command.run();
expect(response).not.toBeNull();
expect(response.success).toEqual(false);
expect(response).toEqual(
Response.error(
new MessageResponse(
"An organization you are a member of is using Key Connector. In order to access the vault, you must confirm the Key Connector domain now via the web vault. You have been logged out.",
null,
),
),
);
expect(logout).toHaveBeenCalled();
});
it("should logout and return error response if interaction answer is cancel", async () => {
process.env.BW_NOINTERACTION = "false";
(createPromptModule as jest.Mock).mockImplementation(() =>
jest.fn((prompt) => {
assertPrompt(prompt);
return Promise.resolve({ confirm: "cancel" });
}),
);
const response = await command.run();
expect(response).not.toBeNull();
expect(response.success).toEqual(false);
expect(response).toEqual(Response.error("You have been logged out."));
expect(logout).toHaveBeenCalled();
});
it("should convert new sso user to key connector and return success response if answer is confirmed", async () => {
process.env.BW_NOINTERACTION = "false";
(createPromptModule as jest.Mock).mockImplementation(() =>
jest.fn((prompt) => {
assertPrompt(prompt);
return Promise.resolve({ confirm: "confirmed" });
}),
);
const response = await command.run();
expect(response).not.toBeNull();
expect(response.success).toEqual(true);
expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(userId);
});
it("should logout and throw error if convert new sso user to key connector failed", async () => {
process.env.BW_NOINTERACTION = "false";
(createPromptModule as jest.Mock).mockImplementation(() =>
jest.fn((prompt) => {
assertPrompt(prompt);
return Promise.resolve({ confirm: "confirmed" });
}),
);
keyConnectorService.convertNewSsoUserToKeyConnector.mockRejectedValue(
new Error("Migration failed"),
);
await expect(command.run()).rejects.toThrow("Migration failed");
expect(logout).toHaveBeenCalled();
});
function assertPrompt(prompt: unknown) {
expect(typeof prompt).toEqual("object");
expect(prompt).toHaveProperty("type");
expect(prompt).toHaveProperty("name");
expect(prompt).toHaveProperty("message");
expect(prompt).toHaveProperty("choices");
const promptObj = prompt as Record<string, unknown>;
expect(promptObj["type"]).toEqual("list");
expect(promptObj["name"]).toEqual("confirm");
expect(promptObj["message"]).toEqual(
`Please confirm the domain below with your organization administrator. Key Connector domain: ${keyConnectorUrl}`,
);
expect(promptObj["choices"]).toBeInstanceOf(Array);
const choices = promptObj["choices"] as Array<Record<string, unknown>>;
expect(choices).toHaveLength(2);
expect(choices[0]).toEqual({
name: "Confirm",
value: "confirmed",
});
expect(choices[1]).toEqual({
name: "Log out",
value: "cancel",
});
}
});
});

View File

@@ -0,0 +1,62 @@
import * as inquirer from "inquirer";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { Response } from "../models/response";
import { MessageResponse } from "../models/response/message.response";
export class ConfirmKeyConnectorDomainCommand {
constructor(
private readonly userId: UserId,
private readonly keyConnectorUrl: string,
private keyConnectorService: KeyConnectorService,
private logout: () => Promise<void>,
private i18nService: I18nService,
) {}
async run(): Promise<Response> {
// If no interaction available, alert user to use web vault
const canInteract = process.env.BW_NOINTERACTION !== "true";
if (!canInteract) {
await this.logout();
return Response.error(
new MessageResponse(
this.i18nService.t("organizationUsingKeyConnectorConfirmLoggedOut"),
null,
),
);
}
const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({
type: "list",
name: "confirm",
message: this.i18nService.t("confirmKeyConnectorDomain", this.keyConnectorUrl),
choices: [
{
name: this.i18nService.t("confirm"),
value: "confirmed",
},
{
name: this.i18nService.t("logOut"),
value: "cancel",
},
],
});
if (answer.confirm === "confirmed") {
try {
await this.keyConnectorService.convertNewSsoUserToKeyConnector(this.userId);
} catch (e) {
await this.logout();
throw e;
}
return Response.success();
} else {
await this.logout();
return Response.error(this.i18nService.t("youHaveBeenLoggedOut"));
}
}
}

View File

@@ -218,5 +218,20 @@
},
"myItems": {
"message": "My Items"
},
"organizationUsingKeyConnectorConfirmLoggedOut": {
"message": "An organization you are a member of is using Key Connector. In order to access the vault, you must confirm the Key Connector domain now via the web vault. You have been logged out."
},
"confirmKeyConnectorDomain": {
"message": "Please confirm the domain below with your organization administrator. Key Connector domain: $KEYCONNECTORDOMAIN$",
"placeholders": {
"keyConnectorDomain": {
"content": "$1",
"example": "Key Connector domain"
}
}
},
"confirm": {
"message": "Confirm"
}
}

View File

@@ -4,6 +4,7 @@ import * as FormData from "form-data";
import { HttpsProxyAgent } from "https-proxy-agent";
import * as fe from "node-fetch";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
@@ -28,6 +29,7 @@ export class NodeApiService extends ApiService {
logService: LogService,
logoutCallback: () => Promise<void>,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
accountService: AccountService,
customUserAgent: string = null,
) {
super(
@@ -39,6 +41,7 @@ export class NodeApiService extends ApiService {
logService,
logoutCallback,
vaultTimeoutSettingsService,
accountService,
{ createRequest: (url, request) => new Request(url, request) },
customUserAgent,
);

View File

@@ -113,10 +113,13 @@ import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal";
import { AuditService } from "@bitwarden/common/services/audit.service";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider";
import { buildExtensionRegistry } from "@bitwarden/common/tools/extension/factory";
import {
PasswordStrengthService,
PasswordStrengthServiceAbstraction,
} from "@bitwarden/common/tools/password-strength";
import { createSystemServiceProvider } from "@bitwarden/common/tools/providers";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service";
import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider";
import { SendService } from "@bitwarden/common/tools/send/services/send.service";
@@ -504,12 +507,13 @@ export class ServiceContainer {
this.logService,
logoutCallback,
this.vaultTimeoutSettingsService,
this.accountService,
customUserAgent,
);
this.containerService = new ContainerService(this.keyService, this.encryptService);
this.configApiService = new ConfigApiService(this.apiService, this.tokenService);
this.configApiService = new ConfigApiService(this.apiService);
this.authService = new AuthService(
this.accountService,
@@ -601,6 +605,7 @@ export class ServiceContainer {
this.kdfConfigService,
this.keyService,
this.stateProvider,
this.configService,
customUserAgent,
);
@@ -814,8 +819,16 @@ export class ServiceContainer {
this.encryptService,
this.pinService,
this.accountService,
this.sdkService,
this.restrictedItemTypesService,
createSystemServiceProvider(
new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService),
this.stateProvider,
this.policyService,
buildExtensionRegistry(),
this.logService,
this.platformUtilsService,
this.configService,
),
);
this.individualExportService = new IndividualVaultExportService(

View File

@@ -447,6 +447,32 @@ dependencies = [
"tokio-util",
]
[[package]]
name = "bitwarden_chromium_importer"
version = "0.0.0"
dependencies = [
"aes",
"aes-gcm",
"anyhow",
"async-trait",
"base64",
"cbc",
"hex",
"homedir",
"log",
"oo7",
"pbkdf2",
"rand 0.9.1",
"rusqlite",
"security-framework",
"serde",
"serde_json",
"sha1",
"tokio",
"winapi",
"windows 0.61.1",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -586,9 +612,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.38"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000"
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
dependencies = [
"clap_builder",
"clap_derive",
@@ -596,9 +622,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.38"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120"
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
dependencies = [
"anstream",
"anstyle",
@@ -608,9 +634,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.32"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
dependencies = [
"heck",
"proc-macro2",
@@ -897,6 +923,7 @@ dependencies = [
"rsa",
"russh-cryptovec",
"scopeguard",
"secmem-proc",
"security-framework",
"security-framework-sys",
"sha2",
@@ -923,6 +950,7 @@ dependencies = [
"anyhow",
"autotype",
"base64",
"bitwarden_chromium_importer",
"desktop_core",
"hex",
"log",
@@ -1166,6 +1194,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1425,6 +1465,18 @@ name = "hashbrown"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.3",
]
[[package]]
name = "heck"
@@ -1690,6 +1742,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "link-cplusplus"
version = "1.0.10"
@@ -2643,6 +2706,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rusqlite"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "russh-cryptovec"
version = "0.7.3"
@@ -2694,6 +2771,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "rustix-linux-procfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
dependencies = [
"once_cell",
"rustix 1.0.7",
]
[[package]]
name = "rustversion"
version = "1.0.20"
@@ -2772,6 +2859,21 @@ dependencies = [
"zeroize",
]
[[package]]
name = "secmem-proc"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473559b1d28f530c3a9b5f91a2866053e2b1c528a0e43dae83048139c99490c2"
dependencies = [
"anyhow",
"cfg-if",
"libc",
"rustix 1.0.7",
"rustix-linux-procfs",
"thiserror 2.0.12",
"windows 0.61.1",
]
[[package]]
name = "security-framework"
version = "3.1.0"
@@ -2847,6 +2949,17 @@ dependencies = [
"syn",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.8"
@@ -3179,6 +3292,7 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
@@ -3497,6 +3611,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"

View File

@@ -2,6 +2,7 @@
resolver = "2"
members = [
"autotype",
"bitwarden_chromium_importer",
"core",
"macos_provider",
"napi",
@@ -21,7 +22,6 @@ anyhow = "=1.0.94"
arboard = { version = "=3.6.0", default-features = false }
ashpd = "=0.11.0"
base64 = "=0.22.1"
bindgen = "=0.72.0"
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" }
byteorder = "=1.5.0"
bytes = "=1.10.1"
@@ -49,6 +49,7 @@ rand = "=0.9.1"
rsa = "=0.9.6"
russh-cryptovec = "=0.7.3"
scopeguard = "=1.2.0"
secmem-proc = "=0.3.7"
security-framework = "=3.1.0"
security-framework-sys = "=2.13.0"
serde = "=1.0.209"

View File

@@ -0,0 +1,38 @@
[package]
name = "bitwarden_chromium_importer"
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[dependencies]
aes = { workspace = true }
aes-gcm = "=0.10.3"
anyhow = { workspace = true }
async-trait = "=0.1.88"
base64 = { workspace = true }
cbc = { workspace = true, features = ["alloc"] }
hex = { workspace = true }
homedir = { workspace = true }
log = { workspace = true }
pbkdf2 = "=0.12.2"
rand = { workspace = true }
rusqlite = { version = "=0.35.0", features = ["bundled"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha1 = "=0.10.6"
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
tokio = { workspace = true, features = ["full"] }
winapi = { version = "=0.3.9", features = ["dpapi", "memoryapi"] }
windows = { workspace = true, features = ["Win32_Security", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_Services", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
[target.'cfg(target_os = "linux")'.dependencies]
oo7 = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,156 @@
# Windows ABE Architecture
## Overview
The Windows Application Bound Encryption (ABE) consists of three main components that work together:
- **client library** -- Library that is part of the desktop client application
- **admin.exe** -- Service launcher running as ADMINISTRATOR
- **service.exe** -- Background Windows service running as SYSTEM
_(The names of the binaries will be changed for the released product.)_
## The goal
The goal of this subsystem is to decrypt the master encryption key with which the login information
is encrypted on the local system in Windows. This applies to the most recent versions of Chrome and
Edge (untested yet) that are using the ABE/v20 encryption scheme for some of the local profiles.
The general idea of this encryption scheme is that Chrome generates a unique random encryption key,
then encrypts it at the user level with a fixed key. It then sends it to the Windows Data Protection
API at the user level, and then, using an installed service, encrypts it with the Windows Data
Protection API at the system level on top of that. This triply encrypted key is later stored in the
`Local State` file.
The next paragraphs describe what is done at each level to decrypt the key.
## 1. Client library
This is a Rust module that is part of the Chromium importer. It only compiles and runs on Windows
(see `abe.rs` and `abe_config.rs`). Its main task is to launch `admin.exe` with elevated privileges
by presenting the user with the UAC screen. See the `abe::decrypt_with_admin_and_service` invocation
in `windows.rs`.
This function takes three arguments:
1. Absolute path to `admin.exe`
2. Absolute path to `service.exe`
3. Base64 string of the ABE key extracted from the browser's local state
It's not possible to install the service from the user-level executable. So first, we have to
elevate the privileges and run `admin.exe` as ADMINISTRATOR. This is done by calling `ShellExecute`
with the `runas` verb. Since it's not trivial to read the standard output from an application
launched in this way, a named pipe server is created at the user level, which waits for the response
from `admin.exe` after it has been launched.
The name of the service executable and the data to be decrypted are passed via the command line to
`admin.exe` like this:
```bat
admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..."
```
**At this point, the user must permit the action to be performed on the UAC screen.**
## 2. Admin executable
This executable receives the full path of `service.exe` and the data to be decrypted.
First, it installs the service to run as SYSTEM and waits for it to start running. The service
creates a named pipe server that the admin-level executable communicates with (see the `service.exe`
description further down).
It sends the base64 string to the pipe server in a raw message and waits for the answer. The answer
could be a success or a failure. In case of success, it's a base64 string decrypted at the system
level. In case of failure, it's an error message prefixed with an `!`. In either case, the response
is sent to the named pipe server created by the user. The user responds with `ok` (ignored).
After that, the executable stops and uninstalls the service and then exits.
## 3. System service
The service starts and creates a named pipe server for communication between `admin.exe` and the
system service. Please note that it is not possible to communicate between the user and the system
service directly via a named pipe. Thus, this three-layered approach is necessary.
Once the service is started, it waits for the incoming message via the named pipe. The expected
message is a base64 string to be decrypted. The data is decrypted via the Windows Data Protection
API `CryptUnprotectData` and sent back in response to this incoming message in base64 encoding. In
case of an error, the error message is sent back prefixed with an `!`.
The service keeps running and servicing more requests if there are any, until it's stopped and
removed from the system. Even though we send only one request, the service is designed to handle as
many clients with as many messages as needed and could be installed on the system permanently if
necessary.
## 4. Back to client library
The decrypted base64-encoded string comes back from the admin executable to the named pipe server at
the user level. At this point, it has been decrypted only once at the system level.
In the next step, the string is decrypted at the user level with the same Windows Data Protection
API.
And as the third step, it's decrypted with a hard-coded key found in the `elevation_service.exe`
from the Chrome installation. Based on the version of the encrypted string (encoded in the string
itself), it's either AES-256-GCM or ChaCha20Poly1305 encryption scheme. The details can be found in
`windows.rs`.
After all of these steps, we have the master key which can be used to decrypt the password
information stored in the local database.
## Summary
The Windows ABE decryption process involves a three-tier architecture with named pipe communication:
```mermaid
sequenceDiagram
participant Client as Client Library (User)
participant Admin as admin.exe (Administrator)
participant Service as service.exe (System)
Client->>Client: Create named pipe server
Note over Client: \\.\pipe\BitwardenEncryptionService-admin-user
Client->>Admin: Launch with UAC elevation
Note over Client,Admin: --service-exe c:\path\to\service.exe
Note over Client,Admin: --encrypted QVBQQgEAAADQjJ3fARXRE...
Client->>Client: Wait for response
Admin->>Service: Install & start service
Note over Admin,Service: c:\path\to\service.exe
Service->>Service: Create named pipe server
Note over Service: \\.\pipe\BitwardenEncryptionService-service-admin
Service->>Service: Wait for message
Admin->>Service: Send encrypted data via admin-service pipe
Note over Admin,Service: QVBQQgEAAADQjJ3fARXRE...
Admin->>Admin: Wait for response
Service->>Service: Decrypt with system-level DPAPI
Service->>Admin: Return decrypted data via admin-service pipe
Note over Service,Admin: EjRWeXN0ZW0gU2VydmljZQ...
Admin->>Client: Send result via named user-admin pipe
Note over Client,Admin: EjRWeXN0ZW0gU2VydmljZQ...
Client->>Admin: Send ACK to admin
Note over Client,Admin: ok
Admin->>Service: Stop & uninstall service
Service-->>Admin: Exit
Admin-->>Client: Exit
Client->>Client: Decrypt with user-level DPAPI
Client->>Client: Decrypt with hardcoded key
Note over Client: AES-256-GCM or ChaCha20Poly1305
Client->>Client: Done
```

View File

@@ -0,0 +1,350 @@
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use hex::decode;
use homedir::my_home;
use rusqlite::{params, Connection};
// Platform-specific code
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
mod platform;
//
// Public API
//
#[derive(Debug)]
pub struct ProfileInfo {
pub name: String,
pub folder: String,
#[allow(dead_code)]
pub account_name: Option<String>,
#[allow(dead_code)]
pub account_email: Option<String>,
}
#[derive(Debug)]
pub struct Login {
pub url: String,
pub username: String,
pub password: String,
pub note: String,
}
#[derive(Debug)]
pub struct LoginImportFailure {
pub url: String,
pub username: String,
pub error: String,
}
#[derive(Debug)]
pub enum LoginImportResult {
Success(Login),
Failure(LoginImportFailure),
}
// TODO: Make thus async
pub fn get_installed_browsers() -> Result<Vec<String>> {
let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len());
for (browser, config) in SUPPORTED_BROWSER_MAP.iter() {
let data_dir = get_browser_data_dir(config)?;
if data_dir.exists() {
browsers.push((*browser).to_string());
}
}
Ok(browsers)
}
// TODO: Make thus async
pub fn get_available_profiles(browser_name: &String) -> Result<Vec<ProfileInfo>> {
let (_, local_state) = load_local_state_for_browser(browser_name)?;
Ok(get_profile_info(&local_state))
}
pub async fn import_logins(
browser_name: &String,
profile_id: &String,
) -> Result<Vec<LoginImportResult>> {
let (data_dir, local_state) = load_local_state_for_browser(browser_name)?;
let mut crypto_service = platform::get_crypto_service(browser_name, &local_state)
.map_err(|e| anyhow!("Failed to get crypto service: {}", e))?;
let local_logins = get_logins(&data_dir, profile_id, "Login Data")
.map_err(|e| anyhow!("Failed to query logins: {}", e))?;
// This is not available in all browsers, but there's no harm in trying. If the file doesn't exist we just get an empty vector.
let account_logins = get_logins(&data_dir, profile_id, "Login Data For Account")
.map_err(|e| anyhow!("Failed to query logins: {}", e))?;
// TODO: Do we need a better merge strategy? Maybe ignore duplicates at least?
// TODO: Should we also ignore an error from one of the two imports? If one is successful and the other fails,
// should we still return the successful ones? At the moment it doesn't fail for a missing file, only when
// something goes really wrong.
let all_logins = local_logins
.into_iter()
.chain(account_logins.into_iter())
.collect::<Vec<_>>();
let results = decrypt_logins(all_logins, &mut crypto_service).await;
Ok(results)
}
//
// Private
//
#[derive(Debug)]
struct BrowserConfig {
name: &'static str,
data_dir: &'static str,
}
static SUPPORTED_BROWSER_MAP: LazyLock<
std::collections::HashMap<&'static str, &'static BrowserConfig>,
> = LazyLock::new(|| {
platform::SUPPORTED_BROWSERS
.iter()
.map(|b| (b.name, b))
.collect::<std::collections::HashMap<_, _>>()
});
fn get_browser_data_dir(config: &BrowserConfig) -> Result<PathBuf> {
let dir = my_home()
.map_err(|_| anyhow!("Home directory not found"))?
.ok_or_else(|| anyhow!("Home directory not found"))?
.join(config.data_dir);
Ok(dir)
}
//
// CryptoService
//
#[async_trait]
trait CryptoService: Send {
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String>;
}
#[derive(serde::Deserialize, Clone)]
struct LocalState {
profile: AllProfiles,
#[allow(dead_code)]
os_crypt: Option<OsCrypt>,
}
#[derive(serde::Deserialize, Clone)]
struct AllProfiles {
info_cache: std::collections::HashMap<String, OneProfile>,
}
#[derive(serde::Deserialize, Clone)]
struct OneProfile {
name: String,
gaia_name: Option<String>,
user_name: Option<String>,
}
#[derive(serde::Deserialize, Clone)]
struct OsCrypt {
#[allow(dead_code)]
encrypted_key: Option<String>,
#[allow(dead_code)]
app_bound_encrypted_key: Option<String>,
}
fn load_local_state_for_browser(browser_name: &String) -> Result<(PathBuf, LocalState)> {
let config = SUPPORTED_BROWSER_MAP
.get(browser_name.as_str())
.ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?;
let data_dir = get_browser_data_dir(config)?;
if !data_dir.exists() {
return Err(anyhow!(
"Browser user data directory '{}' not found",
data_dir.display()
));
}
let local_state = load_local_state(&data_dir)?;
Ok((data_dir, local_state))
}
fn load_local_state(browser_dir: &Path) -> Result<LocalState> {
let local_state = std::fs::read_to_string(browser_dir.join("Local State"))
.map_err(|e| anyhow!("Failed to read local state file: {}", e))?;
serde_json::from_str(&local_state)
.map_err(|e| anyhow!("Failed to parse local state JSON: {}", e))
}
fn get_profile_info(local_state: &LocalState) -> Vec<ProfileInfo> {
let mut profile_infos = Vec::new();
for (name, info) in local_state.profile.info_cache.iter() {
profile_infos.push(ProfileInfo {
name: info.name.clone(),
folder: name.clone(),
account_name: info.gaia_name.clone(),
account_email: info.user_name.clone(),
});
}
profile_infos
}
struct EncryptedLogin {
url: String,
username: String,
encrypted_password: Vec<u8>,
encrypted_note: Vec<u8>,
}
fn get_logins(
browser_dir: &Path,
profile_id: &String,
filename: &str,
) -> Result<Vec<EncryptedLogin>> {
let login_data_path = browser_dir.join(profile_id).join(filename);
// Sometimes database files are not present, so nothing to import
if !login_data_path.exists() {
return Ok(vec![]);
}
// When the browser with the current profile is open the database file is locked.
// To access it we need to copy it to a temporary location.
let tmp_db_path = std::env::temp_dir().join(format!(
"tmp-logins-{}-{}.db",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| anyhow!("Failed to retrieve system time: {}", e))?
.as_millis(),
rand::random::<u32>()
));
std::fs::copy(&login_data_path, &tmp_db_path).map_err(|e| {
anyhow!(
"Failed to copy the password database file at {:?}: {}",
login_data_path,
e
)
})?;
let tmp_db_path = tmp_db_path
.to_str()
.ok_or_else(|| anyhow!("Failed to locate database."))?;
let maybe_logins =
query_logins(tmp_db_path).map_err(|e| anyhow!("Failed to query logins: {}", e))?;
// Clean up temp file
let _ = std::fs::remove_file(tmp_db_path);
Ok(maybe_logins)
}
fn hex_to_bytes(hex: &str) -> Vec<u8> {
decode(hex).unwrap_or_default()
}
fn does_table_exist(conn: &Connection, table_name: &str) -> Result<bool, rusqlite::Error> {
let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?1")?;
let exists = stmt.exists(params![table_name])?;
Ok(exists)
}
fn query_logins(db_path: &str) -> Result<Vec<EncryptedLogin>, rusqlite::Error> {
let conn = Connection::open(db_path)?;
let have_logins = does_table_exist(&conn, "logins")?;
let have_password_notes = does_table_exist(&conn, "password_notes")?;
if !have_logins || !have_password_notes {
return Ok(vec![]);
}
let mut stmt = conn.prepare(
r#"
SELECT
l.origin_url AS url,
l.username_value AS username,
hex(l.password_value) AS encryptedPasswordHex,
hex(pn.value) AS encryptedNoteHex
FROM
logins l
LEFT JOIN
password_notes pn ON l.id = pn.parent_id
WHERE
l.blacklisted_by_user = 0
"#,
)?;
let logins_iter = stmt.query_map((), |row| {
let url: String = row.get("url")?;
let username: String = row.get("username")?;
let encrypted_password_hex: String = row.get("encryptedPasswordHex")?;
let encrypted_note_hex: String = row.get("encryptedNoteHex")?;
Ok(EncryptedLogin {
url,
username,
encrypted_password: hex_to_bytes(&encrypted_password_hex),
encrypted_note: hex_to_bytes(&encrypted_note_hex),
})
})?;
let mut logins = Vec::new();
for login in logins_iter {
logins.push(login?);
}
Ok(logins)
}
async fn decrypt_logins(
encrypted_logins: Vec<EncryptedLogin>,
crypto_service: &mut Box<dyn CryptoService>,
) -> Vec<LoginImportResult> {
let mut results = Vec::with_capacity(encrypted_logins.len());
for encrypted_login in encrypted_logins {
let result = decrypt_login(encrypted_login, crypto_service).await;
results.push(result);
}
results
}
async fn decrypt_login(
encrypted_login: EncryptedLogin,
crypto_service: &mut Box<dyn CryptoService>,
) -> LoginImportResult {
let maybe_password = crypto_service
.decrypt_to_string(&encrypted_login.encrypted_password)
.await;
match maybe_password {
Ok(password) => {
let note = crypto_service
.decrypt_to_string(&encrypted_login.encrypted_note)
.await
.unwrap_or_default();
LoginImportResult::Success(Login {
url: encrypted_login.url,
username: encrypted_login.username,
password,
note,
})
}
Err(e) => LoginImportResult::Failure(LoginImportFailure {
url: encrypted_login.url,
username: encrypted_login.username,
error: e.to_string(),
}),
}
}

View File

@@ -0,0 +1,17 @@
//! Cryptographic primitives used in the SDK
use anyhow::{Result, anyhow};
use aes::cipher::{
block_padding::Pkcs7, generic_array::GenericArray, typenum::U32, BlockDecryptMut, KeyIvInit,
};
pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray<u8, U32>) -> Result<Vec<u8>> {
let iv = GenericArray::from_slice(iv);
let mut data = data.to_vec();
return cbc::Decryptor::<aes::Aes256>::new(&key, iv)
.decrypt_padded_mut::<Pkcs7>(&mut data)
.map_err(|_| anyhow!("Failed to decrypt data"))?;
Ok(data)
}

View File

@@ -0,0 +1 @@
pub mod chromium;

View File

@@ -0,0 +1,153 @@
use std::collections::HashMap;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use oo7::XDG_SCHEMA_ATTRIBUTE;
use crate::chromium::{BrowserConfig, CryptoService, LocalState};
mod util;
//
// Public API
//
// TODO: It's possible that there might be multiple possible data directories, depending on the installation method (e.g., snap, flatpak, etc.).
pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [
BrowserConfig {
name: "Chrome",
data_dir: ".config/google-chrome",
},
BrowserConfig {
name: "Chromium",
data_dir: "snap/chromium/common/chromium",
},
BrowserConfig {
name: "Brave",
data_dir: "snap/brave/current/.config/BraveSoftware/Brave-Browser",
},
BrowserConfig {
name: "Opera",
data_dir: "snap/opera/current/.config/opera",
},
];
pub fn get_crypto_service(
browser_name: &String,
_local_state: &LocalState,
) -> Result<Box<dyn CryptoService>> {
let config = KEYRING_CONFIG
.iter()
.find(|b| b.browser == browser_name)
.ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?;
let service = LinuxCryptoService::new(config);
Ok(Box::new(service))
}
//
// Private
//
#[derive(Debug)]
struct KeyringConfig {
browser: &'static str,
application_id: &'static str,
}
const KEYRING_CONFIG: [KeyringConfig; SUPPORTED_BROWSERS.len()] = [
KeyringConfig {
browser: "Chrome",
application_id: "chrome",
},
KeyringConfig {
browser: "Chromium",
application_id: "chromium",
},
KeyringConfig {
browser: "Brave",
application_id: "brave",
},
KeyringConfig {
browser: "Opera",
application_id: "opera",
},
];
const IV: [u8; 16] = [0x20; 16];
const V10_KEY: [u8; 16] = [
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78,
];
struct LinuxCryptoService {
config: &'static KeyringConfig,
v11_key: Option<Vec<u8>>,
}
impl LinuxCryptoService {
fn new(config: &'static KeyringConfig) -> Self {
Self {
config,
v11_key: None,
}
}
fn decrypt_v10(&self, encrypted: &[u8]) -> Result<String> {
decrypt(&V10_KEY, encrypted)
}
async fn decrypt_v11(&mut self, encrypted: &[u8]) -> Result<String> {
if self.v11_key.is_none() {
let master_password = get_master_password(self.config.application_id).await?;
self.v11_key = Some(util::derive_saltysalt(&master_password, 1)?);
}
let key = self
.v11_key
.as_ref()
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
decrypt(key, encrypted)
}
}
#[async_trait]
impl CryptoService for LinuxCryptoService {
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String> {
let (version, password) =
util::split_encrypted_string_and_validate(encrypted, &["v10", "v11"])?;
let result = match version {
"v10" => self.decrypt_v10(password),
"v11" => self.decrypt_v11(password).await,
_ => Err(anyhow!("Logic error: unreachable code")),
}?;
Ok(result)
}
}
fn decrypt(key: &[u8], encrypted: &[u8]) -> Result<String> {
let plaintext = util::decrypt_aes_128_cbc(key, &IV, encrypted)?;
String::from_utf8(plaintext).map_err(|e| anyhow!("UTF-8 error: {:?}", e))
}
async fn get_master_password(application_tag: &str) -> Result<Vec<u8>> {
let keyring = oo7::Keyring::new().await?;
keyring.unlock().await?;
let attributes = HashMap::from([
(
XDG_SCHEMA_ATTRIBUTE,
"chrome_libsecret_os_crypt_password_v2",
),
("application", application_tag),
]);
let results = keyring.search_items(&attributes).await?;
match results.first() {
Some(r) => {
let secret = r.secret().await?;
Ok(secret.to_vec())
}
None => Err(anyhow!("The master password not found in the keyring")),
}
}

View File

@@ -0,0 +1,164 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use security_framework::passwords::get_generic_password;
use crate::chromium::{BrowserConfig, CryptoService, LocalState};
mod util;
//
// Public API
//
pub const SUPPORTED_BROWSERS: [BrowserConfig; 7] = [
BrowserConfig {
name: "Chrome",
data_dir: "Library/Application Support/Google/Chrome",
},
BrowserConfig {
name: "Chromium",
data_dir: "Library/Application Support/Chromium",
},
BrowserConfig {
name: "Microsoft Edge",
data_dir: "Library/Application Support/Microsoft Edge",
},
BrowserConfig {
name: "Brave",
data_dir: "Library/Application Support/BraveSoftware/Brave-Browser",
},
BrowserConfig {
name: "Arc",
data_dir: "Library/Application Support/Arc/User Data",
},
BrowserConfig {
name: "Opera",
data_dir: "Library/Application Support/com.operasoftware.Opera",
},
BrowserConfig {
name: "Vivaldi",
data_dir: "Library/Application Support/Vivaldi",
},
];
pub fn get_crypto_service(
browser_name: &String,
_local_state: &LocalState,
) -> Result<Box<dyn CryptoService>> {
let config = KEYCHAIN_CONFIG
.iter()
.find(|b| b.browser == browser_name)
.ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?;
Ok(Box::new(MacCryptoService::new(config)))
}
//
// Private
//
#[derive(Debug)]
struct KeychainConfig {
browser: &'static str,
service: &'static str,
account: &'static str,
}
const KEYCHAIN_CONFIG: [KeychainConfig; SUPPORTED_BROWSERS.len()] = [
KeychainConfig {
browser: "Chrome",
service: "Chrome Safe Storage",
account: "Chrome",
},
KeychainConfig {
browser: "Chromium",
service: "Chromium Safe Storage",
account: "Chromium",
},
KeychainConfig {
browser: "Microsoft Edge",
service: "Microsoft Edge Safe Storage",
account: "Microsoft Edge",
},
KeychainConfig {
browser: "Brave",
service: "Brave Safe Storage",
account: "Brave",
},
KeychainConfig {
browser: "Arc",
service: "Arc Safe Storage",
account: "Arc",
},
KeychainConfig {
browser: "Opera",
service: "Opera Safe Storage",
account: "Opera",
},
KeychainConfig {
browser: "Vivaldi",
service: "Vivaldi Safe Storage",
account: "Vivaldi",
},
];
const IV: [u8; 16] = [0x20; 16]; // 16 bytes of 0x20 (space character)
//
// CryptoService
//
struct MacCryptoService {
config: &'static KeychainConfig,
master_key: Option<Vec<u8>>,
}
impl MacCryptoService {
fn new(config: &'static KeychainConfig) -> Self {
Self {
config,
master_key: None,
}
}
}
#[async_trait]
impl CryptoService for MacCryptoService {
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String> {
if encrypted.is_empty() {
return Ok(String::new());
}
// On macOS only v10 is supported
let (_, no_prefix) = util::split_encrypted_string_and_validate(encrypted, &["v10"])?;
// This might bring up the admin password prompt
if self.master_key.is_none() {
self.master_key = Some(get_master_key(self.config.service, self.config.account)?);
}
let key = self
.master_key
.as_ref()
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
let plaintext = util::decrypt_aes_128_cbc(key, &IV, no_prefix)
.map_err(|e| anyhow!("Failed to decrypt: {}", e))?;
let plaintext =
String::from_utf8(plaintext).map_err(|e| anyhow!("Invalid UTF-8: {}", e))?;
Ok(plaintext)
}
}
fn get_master_key(service: &str, account: &str) -> Result<Vec<u8>> {
let master_password = get_master_password(service, account)?;
let key = util::derive_saltysalt(&master_password, 1003)?;
Ok(key)
}
fn get_master_password(service: &str, account: &str) -> Result<Vec<u8>> {
let password = get_generic_password(service, account)
.map_err(|e| anyhow!("Failed to get password from keychain: {}", e))?;
Ok(password)
}

View File

@@ -0,0 +1,43 @@
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
use anyhow::{anyhow, Result};
use pbkdf2::{hmac::Hmac, pbkdf2};
use sha1::Sha1;
pub fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> {
if encrypted.len() < 3 {
return Err(anyhow!(
"Corrupted entry: invalid encrypted string length, expected at least 3 bytes, got {}",
encrypted.len()
));
}
let (version, password) = encrypted.split_at(3);
Ok((std::str::from_utf8(version)?, password))
}
pub fn split_encrypted_string_and_validate<'a>(
encrypted: &'a [u8],
supported_versions: &[&str],
) -> Result<(&'a str, &'a [u8])> {
let (version, password) = split_encrypted_string(encrypted)?;
if !supported_versions.contains(&version) {
return Err(anyhow!("Unsupported encryption version: {}", version));
}
Ok((version, password))
}
pub fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
let decryptor = cbc::Decryptor::<aes::Aes128>::new_from_slices(key, iv)?;
let plaintext = decryptor
.decrypt_padded_vec_mut::<Pkcs7>(ciphertext)
.map_err(|e| anyhow!("Failed to decrypt: {}", e))?;
Ok(plaintext)
}
pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result<Vec<u8>> {
let mut key = vec![0u8; 16];
pbkdf2::<Hmac<Sha1>>(password, b"saltysalt", iterations, &mut key)
.map_err(|e| anyhow!("Failed to derive master key: {}", e))?;
Ok(key)
}

View File

@@ -0,0 +1,205 @@
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use winapi::shared::minwindef::{BOOL, BYTE, DWORD};
use winapi::um::{dpapi::CryptUnprotectData, wincrypt::DATA_BLOB};
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use crate::chromium::{BrowserConfig, CryptoService, LocalState};
#[allow(dead_code)]
mod util;
//
// Public API
//
pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [
BrowserConfig {
name: "Chrome",
data_dir: "AppData/Local/Google/Chrome/User Data",
},
BrowserConfig {
name: "Chromium",
data_dir: "AppData/Local/Chromium/User Data",
},
BrowserConfig {
name: "Microsoft Edge",
data_dir: "AppData/Local/Microsoft/Edge/User Data",
},
BrowserConfig {
name: "Brave",
data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data",
},
BrowserConfig {
name: "Opera",
data_dir: "AppData/Roaming/Opera Software/Opera Stable",
},
BrowserConfig {
name: "Vivaldi",
data_dir: "AppData/Local/Vivaldi/User Data",
},
];
pub fn get_crypto_service(
_browser_name: &str,
local_state: &LocalState,
) -> Result<Box<dyn CryptoService>> {
Ok(Box::new(WindowsCryptoService::new(local_state)))
}
//
// CryptoService
//
struct WindowsCryptoService {
master_key: Option<Vec<u8>>,
encrypted_key: Option<String>,
}
impl WindowsCryptoService {
pub(crate) fn new(local_state: &LocalState) -> Self {
Self {
master_key: None,
encrypted_key: local_state
.os_crypt
.as_ref()
.and_then(|c| c.encrypted_key.clone()),
}
}
}
#[async_trait]
impl CryptoService for WindowsCryptoService {
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String> {
if encrypted.is_empty() {
return Ok(String::new());
}
// On Windows only v10 and v20 are supported at the moment
let (version, no_prefix) =
util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?;
// v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes auth tag]
const IV_SIZE: usize = 12;
const TAG_SIZE: usize = 16;
const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE;
if no_prefix.len() < MIN_LENGTH {
return Err(anyhow!(
"Corrupted entry: expected at least {} bytes, got {} bytes",
MIN_LENGTH,
no_prefix.len()
));
}
// Allow empty passwords
if no_prefix.len() == MIN_LENGTH {
return Ok(String::new());
}
if self.master_key.is_none() {
self.master_key = Some(self.get_master_key(version)?);
}
let key = self
.master_key
.as_ref()
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
let key = Key::<Aes256Gcm>::from_slice(key);
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]);
let decrypted_bytes = cipher
.decrypt(nonce, no_prefix[IV_SIZE..].as_ref())
.map_err(|e| anyhow!("Decryption failed: {}", e))?;
let plaintext = String::from_utf8(decrypted_bytes)
.map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?;
Ok(plaintext)
}
}
impl WindowsCryptoService {
fn get_master_key(&mut self, version: &str) -> Result<Vec<u8>> {
match version {
"v10" => self.get_master_key_v10(),
_ => Err(anyhow!("Unsupported version: {}", version)),
}
}
fn get_master_key_v10(&mut self) -> Result<Vec<u8>> {
if self.encrypted_key.is_none() {
return Err(anyhow!(
"Encrypted master key is not found in the local browser state"
));
}
let key = self
.encrypted_key
.as_ref()
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
let key_bytes = BASE64_STANDARD
.decode(key)
.map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?;
if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" {
return Err(anyhow!("Encrypted master key is not encrypted with DPAPI"));
}
let key = unprotect_data_win(&key_bytes[5..])
.map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?;
Ok(key)
}
}
fn unprotect_data_win(data: &[u8]) -> Result<Vec<u8>> {
if data.is_empty() {
return Ok(Vec::new());
}
let mut data_in = DATA_BLOB {
cbData: data.len() as DWORD,
pbData: data.as_ptr() as *mut BYTE,
};
let mut data_out = DATA_BLOB {
cbData: 0,
pbData: std::ptr::null_mut(),
};
let result: BOOL = unsafe {
// BOOL from winapi (i32)
CryptUnprotectData(
&mut data_in,
std::ptr::null_mut(), // ppszDataDescr: *mut LPWSTR (*mut *mut u16)
std::ptr::null_mut(), // pOptionalEntropy: *mut DATA_BLOB
std::ptr::null_mut(), // pvReserved: LPVOID (*mut c_void)
std::ptr::null_mut(), // pPromptStruct: *mut CRYPTPROTECT_PROMPTSTRUCT
0, // dwFlags: DWORD
&mut data_out,
)
};
if result == 0 {
return Err(anyhow!("CryptUnprotectData failed"));
}
if data_out.pbData.is_null() || data_out.cbData == 0 {
return Ok(Vec::new());
}
let output_slice =
unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize) };
unsafe {
if !data_out.pbData.is_null() {
LocalFree(Some(HLOCAL(data_out.pbData as *mut std::ffi::c_void)));
}
}
Ok(output_slice.to_vec())
}

View File

@@ -35,7 +35,7 @@ function buildProxyBin(target, release = true) {
const targetArg = target ? `--target ${target}` : "";
const releaseArg = release ? "--release" : "";
child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")});
if (target) {
// Copy the resulting binary to the dist folder
const targetFolder = release ? "release" : "debug";

View File

@@ -39,6 +39,7 @@ rand = { workspace = true }
rsa = { workspace = true }
russh-cryptovec = { workspace = true }
scopeguard = { workspace = true }
secmem-proc = { workspace = true }
sha2 = { workspace = true }
ssh-encoding = { workspace = true }
ssh-key = { workspace = true, features = [

View File

@@ -20,6 +20,8 @@ pub fn disable_coredumps() -> Result<()> {
rlim_cur: 0,
rlim_max: 0,
};
println!("[Process Isolation] Disabling core dumps via setrlimit");
if unsafe { libc::setrlimit(RLIMIT_CORE, &rlimit) } != 0 {
let e = std::io::Error::last_os_error();
return Err(anyhow::anyhow!(
@@ -44,11 +46,17 @@ pub fn is_core_dumping_disabled() -> Result<bool> {
Ok(rlimit.rlim_cur == 0 && rlimit.rlim_max == 0)
}
pub fn disable_memory_access() -> Result<()> {
pub fn isolate_process() -> Result<()> {
let pid = std::process::id();
println!(
"[Process Isolation] Disabling ptrace and memory access for main ({}) via PR_SET_DUMPABLE",
pid
);
if unsafe { libc::prctl(PR_SET_DUMPABLE, 0) } != 0 {
let e = std::io::Error::last_os_error();
return Err(anyhow::anyhow!(
"failed to disable memory dumping, memory is dumpable by other processes {}",
"failed to disable memory dumping, memory may be accessible by other processes {}",
e
));
}

View File

@@ -8,6 +8,17 @@ pub fn is_core_dumping_disabled() -> Result<bool> {
bail!("Not implemented on Mac")
}
pub fn disable_memory_access() -> Result<()> {
bail!("Not implemented on Mac")
pub fn isolate_process() -> Result<()> {
let pid: u32 = std::process::id();
println!(
"[Process Isolation] Disabling ptrace on main process ({}) via PT_DENY_ATTACH",
pid
);
secmem_proc::harden_process().map_err(|e| {
anyhow::anyhow!(
"failed to disable memory dumping, memory may be accessible by other processes {}",
e
)
})
}

View File

@@ -1,3 +1,16 @@
//! This module implements process isolation, which aims to protect
//! a process from dumping memory to disk when crashing, and from
//! userspace memory access.
//!
//! On Windows, by default most userspace apps can read the memory of all
//! other apps, and attach debuggers. On Mac, this is not possible, and only
//! after granting developer permissions can an app attach to processes via
//! ptrace / read memory. On Linux, this depends on the distro / configuration of yama
//! `https://linux-audit.com/protect-ptrace-processes-kernel-yama-ptrace_scope/`
//! For instance, ubuntu prevents ptrace of other processes by default.
//! On Fedora, there are change proposals but ptracing is still possible unless
//! otherwise configured.
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]

View File

@@ -8,6 +8,17 @@ pub fn is_core_dumping_disabled() -> Result<bool> {
bail!("Not implemented on Windows")
}
pub fn disable_memory_access() -> Result<()> {
bail!("Not implemented on Windows")
pub fn isolate_process() -> Result<()> {
let pid: u32 = std::process::id();
println!(
"[Process Isolation] Isolating main process via DACL {}",
pid
);
secmem_proc::harden_process().map_err(|e| {
anyhow::anyhow!(
"failed to isolate process, memory may be accessible by other processes {}",
e
)
})
}

View File

@@ -17,6 +17,7 @@ manual_test = []
anyhow = { workspace = true }
autotype = { path = "../autotype" }
base64 = { workspace = true }
bitwarden_chromium_importer = { path = "../bitwarden_chromium_importer" }
desktop_core = { path = "../core" }
hex = { workspace = true }
log = { workspace = true }

View File

@@ -82,7 +82,7 @@ export declare namespace sshagent {
export declare namespace processisolations {
export function disableCoredumps(): Promise<void>
export function isCoreDumpingDisabled(): Promise<boolean>
export function disableMemoryAccess(): Promise<void>
export function isolateProcess(): Promise<void>
}
export declare namespace powermonitors {
export function onLock(callback: (err: Error | null, ) => any): Promise<void>
@@ -208,6 +208,30 @@ export declare namespace logging {
}
export function initNapiLog(jsLogFn: (err: Error | null, arg0: LogLevel, arg1: string) => any): void
}
export declare namespace chromium_importer {
export interface ProfileInfo {
id: string
name: string
}
export interface Login {
url: string
username: string
password: string
note: string
}
export interface LoginImportFailure {
url: string
username: string
error: string
}
export interface LoginImportResult {
login?: Login
failure?: LoginImportFailure
}
export function getInstalledBrowsers(): Promise<Array<string>>
export function getAvailableProfiles(browser: string): Promise<Array<ProfileInfo>>
export function importLogins(browser: string, profileId: string): Promise<Array<LoginImportResult>>
}
export declare namespace autotype {
export function getForegroundWindowTitle(): string
export function typeInput(input: Array<number>): void

View File

@@ -336,8 +336,8 @@ pub mod processisolations {
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn disable_memory_access() -> napi::Result<()> {
desktop_core::process_isolation::disable_memory_access()
pub async fn isolate_process() -> napi::Result<()> {
desktop_core::process_isolation::isolate_process()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}
@@ -878,6 +878,96 @@ pub mod logging {
}
}
#[napi]
pub mod chromium_importer {
use bitwarden_chromium_importer::chromium::LoginImportResult as _LoginImportResult;
use bitwarden_chromium_importer::chromium::ProfileInfo as _ProfileInfo;
#[napi(object)]
pub struct ProfileInfo {
pub id: String,
pub name: String,
}
#[napi(object)]
pub struct Login {
pub url: String,
pub username: String,
pub password: String,
pub note: String,
}
#[napi(object)]
pub struct LoginImportFailure {
pub url: String,
pub username: String,
pub error: String,
}
#[napi(object)]
pub struct LoginImportResult {
pub login: Option<Login>,
pub failure: Option<LoginImportFailure>,
}
impl From<_LoginImportResult> for LoginImportResult {
fn from(l: _LoginImportResult) -> Self {
match l {
_LoginImportResult::Success(l) => LoginImportResult {
login: Some(Login {
url: l.url,
username: l.username,
password: l.password,
note: l.note,
}),
failure: None,
},
_LoginImportResult::Failure(l) => LoginImportResult {
login: None,
failure: Some(LoginImportFailure {
url: l.url,
username: l.username,
error: l.error,
}),
},
}
}
}
impl From<_ProfileInfo> for ProfileInfo {
fn from(p: _ProfileInfo) -> Self {
ProfileInfo {
id: p.folder,
name: p.name,
}
}
}
#[napi]
pub fn get_installed_browsers() -> napi::Result<Vec<String>> {
bitwarden_chromium_importer::chromium::get_installed_browsers()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub fn get_available_profiles(browser: String) -> napi::Result<Vec<ProfileInfo>> {
bitwarden_chromium_importer::chromium::get_available_profiles(&browser)
.map(|profiles| profiles.into_iter().map(ProfileInfo::from).collect())
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn import_logins(
browser: String,
profile_id: String,
) -> napi::Result<Vec<LoginImportResult>> {
bitwarden_chromium_importer::chromium::import_logins(&browser, &profile_id)
.await
.map(|logins| logins.into_iter().map(LoginImportResult::from).collect())
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}
#[napi]
pub mod autotype {
#[napi]

View File

@@ -330,6 +330,33 @@
"enableBrowserIntegrationFingerprintDesc" | i18n
}}</small>
</div>
<div class="form-group" *ngIf="showEnableAutotype">
<div class="checkbox">
<label for="enableAutotype">
<input
id="enableAutotype"
type="checkbox"
formControlName="enableAutotype"
(change)="saveEnableAutotype()"
/>
{{ "enableAutotypeTransitionKey" | i18n }}
<button
type="button"
bitBadge
variant="success"
(click)="openPremiumDialog()"
*ngIf="!hasPremium"
>
{{ "premium" | i18n }}
</button>
</label>
</div>
<small class="help-block" *ngIf="form.value.enableAutotype">
<b>{{ "important" | i18n }}</b>
{{ "enableAutotypeDescriptionTransitionKey" | i18n }}
<b>{{ "editShortcut" | i18n }}</b></small
>
</div>
<div class="form-group">
<div class="checkbox">
<label for="enableHardwareAcceleration">
@@ -413,22 +440,6 @@
"enableDuckDuckGoBrowserIntegrationDesc" | i18n
}}</small>
</div>
<div class="form-group" *ngIf="showEnableAutotype">
<div class="checkbox">
<label for="enableAutotype">
<input
id="enableAutotype"
type="checkbox"
formControlName="enableAutotype"
(change)="saveEnableAutotype()"
/>
{{ "enableAutotype" | i18n }}
</label>
</div>
<small class="help-block"
><b>{{ "important" | i18n }}</b> {{ "enableAutotypeDescription" | i18n }}</small
>
</div>
<div class="form-group">
<label for="theme">{{ "theme" | i18n }}</label>
<select

View File

@@ -11,6 +11,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { DeviceType } from "@bitwarden/common/enums";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import {
@@ -70,6 +71,8 @@ describe("SettingsComponent", () => {
const keyService = mock<KeyService>();
const dialogService = mock<DialogService>();
const desktopAutotypeService = mock<DesktopAutotypeService>();
const billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
const configService = mock<ConfigService>();
beforeEach(async () => {
jest.clearAllMocks();
@@ -99,7 +102,7 @@ describe("SettingsComponent", () => {
},
{ provide: AccountService, useValue: accountService },
{ provide: BiometricStateService, useValue: biometricStateService },
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: ConfigService, useValue: configService },
{
provide: DesktopAutofillSettingsService,
useValue: desktopAutofillSettingsService,
@@ -127,6 +130,7 @@ describe("SettingsComponent", () => {
{ provide: MessagingService, useValue: messagingService },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: DesktopAutotypeService, useValue: desktopAutotypeService },
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
@@ -177,7 +181,9 @@ describe("SettingsComponent", () => {
i18nService.userSetLocale$ = of("en");
pinServiceAbstraction.isPinSet.mockResolvedValue(false);
policyService.policiesByType$.mockReturnValue(of([null]));
desktopAutotypeService.autotypeEnabled$ = of(false);
desktopAutotypeService.resolvedAutotypeEnabled$ = of(false);
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
configService.getFeatureFlag$.mockReturnValue(of(true));
});
afterEach(() => {
@@ -638,4 +644,27 @@ describe("SettingsComponent", () => {
);
});
});
describe("desktop autotype", () => {
it("autotype should be hidden on mac os", async () => {
// Set OS
platformUtilsService.getDevice.mockReturnValue(DeviceType.MacOsDesktop);
// Recreate component to apply the correct device
fixture = TestBed.createComponent(SettingsComponent);
component = fixture.componentInstance;
await component.ngOnInit();
fixture.detectChanges();
// `enableAutotype` label shouldn't be found
const showEnableAutotypeLabelElement = fixture.debugElement.query(
By.css("label[for='enableAutotype']"),
);
expect(showEnableAutotypeLabelElement).toBeNull();
// `showEnableAutotype` should be false
expect(component.showEnableAutotype).toBe(false);
});
});
});

View File

@@ -17,6 +17,7 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
@@ -50,6 +51,7 @@ import {
SelectModule,
ToastService,
TypographyModule,
BadgeComponent,
} from "@bitwarden/components";
import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management";
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
@@ -58,6 +60,7 @@ import { SetPinComponent } from "../../auth/components/set-pin.component";
import { SshAgentPromptType } from "../../autofill/models/ssh-agent-setting";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopAutotypeService } from "../../autofill/services/desktop-autotype.service";
import { PremiumComponent } from "../../billing/app/accounts/premium.component";
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
import { NativeMessagingManifestService } from "../services/native-messaging-manifest.service";
@@ -67,6 +70,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man
templateUrl: "settings.component.html",
standalone: true,
imports: [
BadgeComponent,
CheckboxModule,
CommonModule,
FormFieldModule,
@@ -130,6 +134,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
pinEnabled$: Observable<boolean> = of(true);
hasPremium: boolean = false;
form = this.formBuilder.group({
// Security
vaultTimeout: [null as VaultTimeout | null],
@@ -158,7 +164,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
sshAgentPromptBehavior: SshAgentPromptType.Always,
allowScreenshots: false,
enableDuckDuckGoBrowserIntegration: false,
enableAutotype: false,
enableAutotype: this.formBuilder.control<boolean>({
value: false,
disabled: true,
}),
theme: [null as Theme | null],
locale: [null as string | null],
});
@@ -193,6 +202,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
private validationService: ValidationService,
private changeDetectorRef: ChangeDetectorRef,
private toastService: ToastService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
) {
this.isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
this.isLinux = this.platformUtilsService.getDevice() === DeviceType.LinuxDesktop;
@@ -268,10 +278,14 @@ export class SettingsComponent implements OnInit, OnDestroy {
// Autotype is for Windows initially
const isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;
const windowsDesktopAutotypeFeatureFlag = await this.configService.getFeatureFlag(
FeatureFlag.WindowsDesktopAutotype,
);
this.showEnableAutotype = isWindows && windowsDesktopAutotypeFeatureFlag;
if (isWindows) {
this.configService
.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype)
.pipe(takeUntil(this.destroy$))
.subscribe((enabled) => {
this.showEnableAutotype = enabled;
});
}
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
@@ -377,7 +391,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.desktopSettingsService.sshAgentPromptBehavior$,
),
allowScreenshots: !(await firstValueFrom(this.desktopSettingsService.preventScreenshots$)),
enableAutotype: await firstValueFrom(this.desktopAutotypeService.autotypeEnabled$),
enableAutotype: await firstValueFrom(this.desktopAutotypeService.resolvedAutotypeEnabled$),
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
locale: await firstValueFrom(this.i18nService.userSetLocale$),
};
@@ -402,6 +416,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.form.controls.vaultTimeoutAction.setValue(action, { emitEvent: false });
});
if (isWindows) {
this.billingAccountProfileStateService
.hasPremiumFromAnySource$(activeAccount.id)
.pipe(takeUntil(this.destroy$))
.subscribe((hasPremium) => {
this.hasPremium = hasPremium;
if (this.hasPremium) {
this.form.controls.enableAutotype.enable();
}
});
}
// Form events
this.form.controls.vaultTimeout.valueChanges
.pipe(
@@ -865,6 +892,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
}
async openPremiumDialog() {
await this.dialogService.open(PremiumComponent);
}
async saveEnableAutotype() {
await this.desktopAutotypeService.setAutotypeEnabledState(this.form.value.enableAutotype);
}

View File

@@ -38,7 +38,7 @@ import {
NewDeviceVerificationComponent,
} from "@bitwarden/auth/angular";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
import { LockComponent } from "@bitwarden/key-management-ui";
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
@@ -296,6 +296,16 @@ const routes: Routes = [
component: ChangePasswordComponent,
canActivate: [authGuard],
},
{
path: "confirm-key-connector-domain",
component: ConfirmKeyConnectorDomainComponent,
canActivate: [],
data: {
pageTitle: {
key: "confirmKeyConnectorDomain",
},
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
],
},
];

View File

@@ -49,6 +49,7 @@ import {
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ClientType } from "@bitwarden/common/enums";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
@@ -462,6 +463,7 @@ const safeProviders: SafeProvider[] = [
ConfigService,
GlobalStateProvider,
PlatformUtilsServiceAbstraction,
BillingAccountProfileStateService,
],
}),
];

View File

@@ -0,0 +1,22 @@
import { ipcMain } from "electron";
import { chromium_importer } from "@bitwarden/desktop-napi";
export class ChromiumImporterService {
constructor() {
ipcMain.handle("chromium_importer.getInstalledBrowsers", async (event) => {
return await chromium_importer.getInstalledBrowsers();
});
ipcMain.handle("chromium_importer.getAvailableProfiles", async (event, browser: string) => {
return await chromium_importer.getAvailableProfiles(browser);
});
ipcMain.handle(
"chromium_importer.importLogins",
async (event, browser: string, profileId: string) => {
return await chromium_importer.importLogins(browser, profileId);
},
);
}
}

View File

@@ -5,6 +5,8 @@
(formLoading)="this.loading = $event"
(formDisabled)="this.disabled = $event"
(onSuccessfulImport)="this.onSuccessfulImport($event)"
[onImportFromBrowser]="this.onImportFromBrowser"
[onLoadProfilesFromBrowser]="this.onLoadProfilesFromBrowser"
></tools-import>
</ng-container>
<ng-container bitDialogFooter>

View File

@@ -28,4 +28,12 @@ export class ImportDesktopComponent {
protected async onSuccessfulImport(organizationId: string): Promise<void> {
this.dialogRef.close();
}
protected onLoadProfilesFromBrowser(browser: string): Promise<any[]> {
return ipc.tools.chromiumImporter.getAvailableProfiles(browser);
}
protected onImportFromBrowser(browser: string, profile: string): Promise<any[]> {
return ipc.tools.chromiumImporter.importLogins(browser, profile);
}
}

View File

@@ -0,0 +1,14 @@
import { ipcRenderer } from "electron";
const chromiumImporter = {
getInstalledBrowsers: (): Promise<string[]> =>
ipcRenderer.invoke("chromium_importer.getInstalledBrowsers"),
getAvailableProfiles: (browser: string): Promise<any[]> =>
ipcRenderer.invoke("chromium_importer.getAvailableProfiles", browser),
importLogins: (browser: string, profileId: string): Promise<any[]> =>
ipcRenderer.invoke("chromium_importer.importLogins", browser, profileId),
};
export default {
chromiumImporter,
};

View File

@@ -3,6 +3,7 @@ import { combineLatest, filter, firstValueFrom, map, Observable, of, switchMap }
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -25,7 +26,7 @@ export const AUTOTYPE_ENABLED = new KeyDefinition<boolean>(
export class DesktopAutotypeService {
private readonly autotypeEnabledState = this.globalStateProvider.get(AUTOTYPE_ENABLED);
autotypeEnabled$: Observable<boolean> = of(false);
resolvedAutotypeEnabled$: Observable<boolean> = of(false);
constructor(
private accountService: AccountService,
@@ -34,6 +35,7 @@ export class DesktopAutotypeService {
private configService: ConfigService,
private globalStateProvider: GlobalStateProvider,
private platformUtilsService: PlatformUtilsService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
) {
ipc.autofill.listenAutotypeRequest(async (windowTitle, callback) => {
const possibleCiphers = await this.matchCiphersToWindowTitle(windowTitle);
@@ -48,23 +50,30 @@ export class DesktopAutotypeService {
async init() {
if (this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop) {
this.autotypeEnabled$ = combineLatest([
this.resolvedAutotypeEnabled$ = combineLatest([
this.autotypeEnabledState.state$,
this.configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype),
this.accountService.activeAccount$.pipe(
map((account) => account?.id),
map((activeAccount) => activeAccount?.id),
switchMap((userId) => this.authService.authStatusFor$(userId)),
),
this.accountService.activeAccount$.pipe(
map((activeAccount) => activeAccount?.id),
switchMap((userId) =>
this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId),
),
),
]).pipe(
map(
([autotypeEnabled, windowsDesktopAutotypeFeatureFlag, authStatus]) =>
([autotypeEnabled, windowsDesktopAutotypeFeatureFlag, authStatus, hasPremium]) =>
autotypeEnabled &&
windowsDesktopAutotypeFeatureFlag &&
authStatus == AuthenticationStatus.Unlocked,
authStatus == AuthenticationStatus.Unlocked &&
hasPremium,
),
);
this.autotypeEnabled$.subscribe((enabled) => {
this.resolvedAutotypeEnabled$.subscribe((enabled) => {
ipc.autofill.configureAutotype(enabled);
});
}

View File

@@ -153,10 +153,7 @@ export class SshAgentService implements OnDestroy {
if (isListRequest) {
const sshCiphers = ciphers.filter(
(cipher) =>
cipher.type === CipherType.SshKey &&
!cipher.isDeleted &&
cipher.organizationId == null,
(cipher) => cipher.type === CipherType.SshKey && !cipher.isDeleted,
);
const keys = sshCiphers.map((cipher) => {
return {
@@ -266,10 +263,7 @@ export class SshAgentService implements OnDestroy {
}
const sshCiphers = ciphers.filter(
(cipher) =>
cipher.type === CipherType.SshKey &&
!cipher.isDeleted &&
cipher.organizationId == null,
(cipher) => cipher.type === CipherType.SshKey && !cipher.isDeleted,
);
const keys = sshCiphers.map((cipher) => {
return {

View File

@@ -61,7 +61,7 @@
>
<b>{{ "premiumPurchase" | i18n }}</b>
</button>
<button type="button" data-dismiss="modal">{{ "close" | i18n }}</button>
<button type="button" bitDialogClose>{{ "close" | i18n }}</button>
<div class="right" *ngIf="!(isPremium$ | async)">
<button
#refreshBtn

View File

@@ -3588,6 +3588,12 @@
"awaitingSSODesc": {
"message": "Please continue to log in using your company credentials."
},
"importDirectlyFromBrowser": {
"message": "Import directly from browser"
},
"browserProfile": {
"message": "Browser Profile"
},
"seeDetailedInstructions": {
"message": "See detailed instructions on our help site at",
"description": "This is followed a by a hyperlink to the help website."
@@ -4080,5 +4086,23 @@
"moreBreadcrumbs": {
"message": "More breadcrumbs",
"description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed."
},
"next": {
"message": "Next"
},
"confirmKeyConnectorDomain": {
"message": "Confirm Key Connector domain"
},
"confirm": {
"message": "Confirm"
},
"enableAutotypeTransitionKey": {
"message": "Enable autotype shortcut"
},
"enableAutotypeDescriptionTransitionKey": {
"message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place."
},
"editShortcut": {
"message": "Edit shortcut"
}
}

View File

@@ -33,6 +33,7 @@ import {
} from "@bitwarden/state-internal";
import { SerializedMemoryStorageService, StorageServiceProvider } from "@bitwarden/storage-core";
import { ChromiumImporterService } from "./app/tools/import/chromium-importer.service";
import { MainDesktopAutotypeService } from "./autofill/main/main-desktop-autotype.service";
import { MainSshAgentService } from "./autofill/main/main-ssh-agent.service";
import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service";
@@ -300,6 +301,8 @@ export class Main {
this.ssoUrlService,
);
new ChromiumImporterService();
this.nativeAutofillMain = new NativeAutofillMain(this.logService, this.windowMain);
void this.nativeAutofillMain.init();

View File

@@ -36,7 +36,7 @@ export class WindowMain {
private windowStateChangeTimer: NodeJS.Timeout;
private windowStates: { [key: string]: WindowState } = {};
private enableAlwaysOnTop = false;
private enableRendererProcessForceCrashReload = false;
private enableRendererProcessForceCrashReload = true;
session: Electron.Session;
readonly defaultWidth = 950;
@@ -149,28 +149,31 @@ export class WindowMain {
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on("ready", async () => {
if (isMac() || isWindows()) {
this.enableRendererProcessForceCrashReload = true;
} else if (isLinux() && !isDev()) {
if (await processisolations.isCoreDumpingDisabled()) {
this.logService.info("Coredumps are disabled in renderer process");
this.enableRendererProcessForceCrashReload = true;
} else {
this.logService.info("Disabling coredumps in main process");
if (!isDev()) {
// This currently breaks the file portal for snap https://github.com/flatpak/xdg-desktop-portal/issues/785
if (!isSnapStore()) {
this.logService.info(
"[Process Isolation] Isolating process from debuggers and memory dumps",
);
try {
await processisolations.disableCoredumps();
await processisolations.isolateProcess();
} catch (e) {
this.logService.error("Failed to disable coredumps", e);
this.logService.error("[Process Isolation] Failed to isolate main process", e);
}
}
// this currently breaks the file portal for snap https://github.com/flatpak/xdg-desktop-portal/issues/785
if (!isSnapStore()) {
this.logService.info("Disabling memory dumps in main process");
try {
await processisolations.disableMemoryAccess();
} catch (e) {
this.logService.error("Failed to disable memory dumps", e);
if (isLinux()) {
if (await processisolations.isCoreDumpingDisabled()) {
this.logService.info("Coredumps are disabled in renderer process");
} else {
this.enableRendererProcessForceCrashReload = false;
this.logService.info("Disabling coredumps in main process");
try {
await processisolations.disableCoredumps();
this.enableRendererProcessForceCrashReload = true;
} catch (e) {
this.logService.error("Failed to disable coredumps", e);
}
}
}
}

View File

@@ -1,5 +1,6 @@
import { contextBridge } from "electron";
import tools from "./app/tools/preload";
import auth from "./auth/preload";
import autofill from "./autofill/preload";
import keyManagement from "./key-management/preload";
@@ -21,6 +22,7 @@ export const ipc = {
autofill,
platform,
keyManagement,
tools,
};
contextBridge.exposeInMainWorld("ipc", ipc);

View File

@@ -0,0 +1,156 @@
<app-organization-free-trial-warning
*ngIf="useOrganizationWarningsService$ | async"
[organization]="organization"
(clicked)="navigateToPaymentMethod()"
>
</app-organization-free-trial-warning>
<app-organization-reseller-renewal-warning
*ngIf="useOrganizationWarningsService$ | async"
[organization]="organization"
>
</app-organization-reseller-renewal-warning>
<ng-container *ngIf="freeTrialWhenWarningsServiceDisabled$ | async as freeTrial">
<bit-banner
id="free-trial-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing"
bannerType="premium"
[showClose]="false"
*ngIf="!refreshing && freeTrial.shownBanner"
>
{{ freeTrial.message }}
<a
bitLink
linkType="secondary"
(click)="navigateToPaymentMethod()"
class="tw-cursor-pointer"
rel="noreferrer noopener"
>
{{ "clickHereToAddPaymentMethod" | i18n }}
</a>
</bit-banner>
</ng-container>
<ng-container *ngIf="resellerWarningWhenWarningsServiceDisabled$ | async as resellerWarning">
<bit-banner
id="reseller-warning-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing"
bannerType="info"
[showClose]="false"
*ngIf="!refreshing"
>
{{ resellerWarning?.message }}
</bit-banner>
</ng-container>
<app-org-vault-header
[filter]="filter"
[loading]="refreshing"
[organization]="organization"
[collection]="selectedCollection"
[searchText]="currentSearchText$ | async"
(onAddCipher)="addCipher($event)"
(onAddCollection)="addCollection()"
(onEditCollection)="editCollection(selectedCollection.node, $event.tab, $event.readonly)"
(onDeleteCollection)="deleteCollection(selectedCollection.node)"
(searchTextChanged)="filterSearchText($event)"
></app-org-vault-header>
<div class="tw-flex tw-flex-row">
<div class="tw-w-1/4 tw-mr-5" *ngIf="!hideVaultFilters">
<app-organization-vault-filter
[organization]="organization"
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
(searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter>
</div>
<div [class]="hideVaultFilters ? 'tw-w-full' : 'tw-w-3/4'">
<bit-toggle-group
*ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode"
[selected]="addAccessStatus$ | async"
(selectedChange)="addAccessToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n"
>
<bit-toggle [value]="0">
{{ "all" | i18n }}
</bit-toggle>
<bit-toggle [value]="1">
{{ "addAccess" | i18n }}
</bit-toggle>
</bit-toggle-group>
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
{{ trashCleanupWarning }}
</bit-callout>
<app-vault-items
#vaultItems
[ciphers]="ciphers"
[collections]="collections"
[allCollections]="allCollections"
[allOrganizations]="organization ? [organization] : []"
[allGroups]="allGroups"
[disabled]="loading"
[showOwner]="false"
[showPermissionsColumn]="true"
[showCollections]="filter.type !== undefined"
[showGroups]="
organization?.useGroups &&
((filter.type === undefined && filter.collectionId === undefined) ||
filter.collectionId !== undefined)
"
[showPremiumFeatures]="organization?.useTotp"
[showBulkMove]="false"
[showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="organization?.canAccessEventLogs"
[showAdminActions]="true"
(onEvent)="onVaultItemsEvent($event)"
[showBulkEditCollectionAccess]="true"
[showBulkAddToCollections]="true"
[viewingOrgVault]="true"
[addAccessStatus]="addAccessStatus$ | async"
[addAccessToggle]="showAddAccessToggle"
[activeCollection]="selectedCollection?.node"
>
</app-vault-items>
<ng-container *ngIf="!performingInitialLoad && isEmpty">
<bit-no-items *ngIf="!showCollectionAccessRestricted">
<span slot="title" class="tw-mt-4 tw-block">{{ "noItemsInList" | i18n }}</span>
<button
slot="button"
bitButton
(click)="addCipher()"
buttonType="primary"
type="button"
*ngIf="
filter.type !== 'trash' &&
filter.collectionId !== Unassigned &&
selectedCollection?.node?.canEditItems(organization)
"
>
<i aria-hidden="true" class="bwi bwi-plus"></i> {{ "newItem" | i18n }}
</button>
</bit-no-items>
<collection-access-restricted
*ngIf="showCollectionAccessRestricted"
[canEditCollection]="selectedCollection?.node?.canEdit(organization)"
[canViewCollectionInfo]="selectedCollection?.node?.canViewCollectionInfo(organization)"
(viewCollectionClicked)="
editCollection(selectedCollection.node, $event.tab, $event.readonly)
"
>
</collection-access-restricted>
</ng-container>
<div
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
*ngIf="performingInitialLoad"
>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
</div>
</div>

View File

@@ -1,19 +1,28 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { canAccessVaultTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { organizationPermissionsGuard } from "../guards/org-permissions.guard";
import { VaultComponent } from "./vault.component";
import { VaultComponent } from "./deprecated_vault.component";
import { vNextVaultComponent } from "./vault.component";
const routes: Routes = [
{
path: "",
component: VaultComponent,
canActivate: [organizationPermissionsGuard(canAccessVaultTab)],
data: { titleId: "vaults" },
},
...featureFlaggedRoute({
defaultComponent: VaultComponent,
flaggedComponent: vNextVaultComponent,
featureFlag: FeatureFlag.CollectionVaultRefactor,
routeOptions: {
data: { titleId: "vaults" },
path: "",
canActivate: [organizationPermissionsGuard(canAccessVaultTab)],
},
}),
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],

View File

@@ -1,156 +1,186 @@
<app-organization-free-trial-warning
*ngIf="useOrganizationWarningsService$ | async"
[organization]="organization"
(clicked)="navigateToPaymentMethod()"
>
</app-organization-free-trial-warning>
<app-organization-reseller-renewal-warning
*ngIf="useOrganizationWarningsService$ | async"
[organization]="organization"
>
</app-organization-reseller-renewal-warning>
<ng-container *ngIf="freeTrialWhenWarningsServiceDisabled$ | async as freeTrial">
<bit-banner
id="free-trial-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing"
bannerType="premium"
[showClose]="false"
*ngIf="!refreshing && freeTrial.shownBanner"
>
{{ freeTrial.message }}
<a
bitLink
linkType="secondary"
(click)="navigateToPaymentMethod()"
class="tw-cursor-pointer"
rel="noreferrer noopener"
>
{{ "clickHereToAddPaymentMethod" | i18n }}
</a>
</bit-banner>
</ng-container>
<ng-container *ngIf="resellerWarningWhenWarningsServiceDisabled$ | async as resellerWarning">
<bit-banner
id="reseller-warning-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing"
bannerType="info"
[showClose]="false"
*ngIf="!refreshing"
>
{{ resellerWarning?.message }}
</bit-banner>
</ng-container>
@let organization = organization$ | async;
@let selectedCollection = selectedCollection$ | async;
@let filter = filter$ | async;
@let refreshing = refreshingSubject$ | async;
@let loading = loading$ | async;
<app-org-vault-header
[filter]="filter"
[loading]="refreshing"
[organization]="organization"
[collection]="selectedCollection"
[searchText]="currentSearchText$ | async"
(onAddCipher)="addCipher($event)"
(onAddCollection)="addCollection()"
(onEditCollection)="editCollection(selectedCollection.node, $event.tab, $event.readonly)"
(onDeleteCollection)="deleteCollection(selectedCollection.node)"
(searchTextChanged)="filterSearchText($event)"
></app-org-vault-header>
<div class="tw-flex tw-flex-row">
<div class="tw-w-1/4 tw-mr-5" *ngIf="!hideVaultFilters">
<app-organization-vault-filter
@if (organization) {
@if (useOrganizationWarningsService$ | async) {
<app-organization-free-trial-warning
[organization]="organization"
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
(searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter>
</div>
<div [class]="hideVaultFilters ? 'tw-w-full' : 'tw-w-3/4'">
<bit-toggle-group
*ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode"
[selected]="addAccessStatus$ | async"
(selectedChange)="addAccessToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n"
(clicked)="navigateToPaymentMethod()"
>
<bit-toggle [value]="0">
{{ "all" | i18n }}
</bit-toggle>
</app-organization-free-trial-warning>
}
<bit-toggle [value]="1">
{{ "addAccess" | i18n }}
</bit-toggle>
</bit-toggle-group>
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
{{ trashCleanupWarning }}
</bit-callout>
<app-vault-items
#vaultItems
[ciphers]="ciphers"
[collections]="collections"
[allCollections]="allCollections"
[allOrganizations]="organization ? [organization] : []"
[allGroups]="allGroups"
[disabled]="loading"
[showOwner]="false"
[showPermissionsColumn]="true"
[showCollections]="filter.type !== undefined"
[showGroups]="
organization?.useGroups &&
((filter.type === undefined && filter.collectionId === undefined) ||
filter.collectionId !== undefined)
"
[showPremiumFeatures]="organization?.useTotp"
[showBulkMove]="false"
[showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="organization?.canAccessEventLogs"
[showAdminActions]="true"
(onEvent)="onVaultItemsEvent($event)"
[showBulkEditCollectionAccess]="true"
[showBulkAddToCollections]="true"
[viewingOrgVault]="true"
[addAccessStatus]="addAccessStatus$ | async"
[addAccessToggle]="showAddAccessToggle"
[activeCollection]="selectedCollection?.node"
@if (useOrganizationWarningsService$ | async) {
<app-organization-reseller-renewal-warning [organization]="organization">
</app-organization-reseller-renewal-warning>
}
@let freeTrial = freeTrialWhenWarningsServiceDisabled$ | async;
@if (!refreshing && freeTrial?.shownBanner) {
<bit-banner
id="free-trial-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing"
bannerType="premium"
[showClose]="false"
>
</app-vault-items>
<ng-container *ngIf="!performingInitialLoad && isEmpty">
<bit-no-items *ngIf="!showCollectionAccessRestricted">
<span slot="title" class="tw-mt-4 tw-block">{{ "noItemsInList" | i18n }}</span>
<button
slot="button"
bitButton
(click)="addCipher()"
buttonType="primary"
type="button"
*ngIf="
filter.type !== 'trash' &&
filter.collectionId !== Unassigned &&
selectedCollection?.node?.canEditItems(organization)
"
>
<i aria-hidden="true" class="bwi bwi-plus"></i> {{ "newItem" | i18n }}
</button>
</bit-no-items>
<collection-access-restricted
*ngIf="showCollectionAccessRestricted"
[canEditCollection]="selectedCollection?.node?.canEdit(organization)"
[canViewCollectionInfo]="selectedCollection?.node?.canViewCollectionInfo(organization)"
(viewCollectionClicked)="
editCollection(selectedCollection.node, $event.tab, $event.readonly)
"
{{ freeTrial.message }}
<a
bitLink
linkType="secondary"
(click)="navigateToPaymentMethod()"
class="tw-cursor-pointer"
rel="noreferrer noopener"
>
</collection-access-restricted>
</ng-container>
<div
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
*ngIf="performingInitialLoad"
{{ "clickHereToAddPaymentMethod" | i18n }}
</a>
</bit-banner>
}
@let resellerWarning = resellerWarningWhenWarningsServiceDisabled$ | async;
@if (!refreshing && resellerWarning) {
<bit-banner
id="reseller-warning-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing"
bannerType="info"
[showClose]="false"
>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
{{ resellerWarning?.message }}
</bit-banner>
}
@if (filter) {
<app-org-vault-header
[filter]="filter"
[loading]="refreshing"
[organization]="organization"
[collection]="selectedCollection"
[searchText]="currentSearchText$ | async"
(onAddCipher)="addCipher($event)"
(onAddCollection)="addCollection()"
(onEditCollection)="editCollection(selectedCollection?.node, $event.tab, $event.readonly)"
(onDeleteCollection)="deleteCollection(selectedCollection?.node)"
(searchTextChanged)="filterSearchText($event)"
></app-org-vault-header>
}
<div class="tw-flex tw-flex-row">
@let hideVaultFilters = hideVaultFilter$ | async;
@if (!hideVaultFilters) {
<div class="tw-w-1/4 tw-mr-5">
<app-organization-vault-filter
[organization]="organization"
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
(searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter>
</div>
}
<div [class]="hideVaultFilters ? 'tw-w-full' : 'tw-w-3/4'">
@if (showAddAccessToggle && activeFilter.selectedCollectionNode) {
<bit-toggle-group
[selected]="addAccessStatus$ | async"
(selectedChange)="addAccessToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n"
>
<bit-toggle [value]="0">
{{ "all" | i18n }}
</bit-toggle>
<bit-toggle [value]="1">
{{ "addAccess" | i18n }}
</bit-toggle>
</bit-toggle-group>
}
@if (activeFilter.isDeleted) {
<bit-callout type="warning">
{{ trashCleanupWarning }}
</bit-callout>
}
@if (filter) {
<app-vault-items
#vaultItems
[ciphers]="ciphers$ | async"
[collections]="collections$ | async"
[allCollections]="allCollections$ | async"
[allOrganizations]="organization ? [organization] : []"
[allGroups]="allGroups$ | async"
[disabled]="loading"
[showOwner]="false"
[showPermissionsColumn]="true"
[showCollections]="filter.type !== undefined"
[showGroups]="
organization?.useGroups &&
((filter.type === undefined && filter.collectionId === undefined) ||
filter.collectionId !== undefined)
"
[showPremiumFeatures]="organization?.useTotp"
[showBulkMove]="false"
[showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="organization?.canAccessEventLogs"
[showAdminActions]="true"
(onEvent)="onVaultItemsEvent($event)"
[showBulkEditCollectionAccess]="true"
[showBulkAddToCollections]="true"
[viewingOrgVault]="true"
[addAccessStatus]="addAccessStatus$ | async"
[addAccessToggle]="showAddAccessToggle"
[activeCollection]="selectedCollection?.node"
>
</app-vault-items>
}
@let showCollectionAccessRestricted = showCollectionAccessRestricted$ | async;
@if (!refreshing && (isEmpty$ | async)) {
@if (!showCollectionAccessRestricted) {
<bit-no-items>
<span slot="title" class="tw-mt-4 tw-block">{{ "noItemsInList" | i18n }}</span>
@if (
filter &&
filter.type !== "trash" &&
filter.collectionId !== Unassigned &&
selectedCollection?.node?.canEditItems(organization)
) {
<button
slot="button"
bitButton
(click)="addCipher()"
buttonType="primary"
type="button"
>
<i aria-hidden="true" class="bwi bwi-plus"></i> {{ "newItem" | i18n }}
</button>
}
</bit-no-items>
} @else {
<collection-access-restricted
[canEditCollection]="selectedCollection?.node?.canEdit(organization)"
[canViewCollectionInfo]="selectedCollection?.node?.canViewCollectionInfo(organization)"
(viewCollectionClicked)="
editCollection(selectedCollection?.node, $event.tab, $event.readonly)
"
>
</collection-access-restricted>
}
}
@if (refreshing) {
<div class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
}
</div>
</div>
</div>
}

View File

@@ -6,9 +6,10 @@ import { ViewComponent } from "../../../vault/individual-vault/view.component";
import { CollectionDialogComponent } from "../shared/components/collection-dialog";
import { CollectionNameBadgeComponent } from "./collection-badge";
import { VaultComponent } from "./deprecated_vault.component";
import { GroupBadgeModule } from "./group-badge/group-badge.module";
import { VaultRoutingModule } from "./vault-routing.module";
import { VaultComponent } from "./vault.component";
import { vNextVaultComponent } from "./vault.component";
@NgModule({
imports: [
@@ -19,6 +20,7 @@ import { VaultComponent } from "./vault.component";
OrganizationBadgeModule,
CollectionDialogComponent,
VaultComponent,
vNextVaultComponent,
ViewComponent,
],
})

View File

@@ -55,10 +55,7 @@
<bit-nav-item
[text]="'eventLogs' | i18n"
route="reporting/events"
*ngIf="
(organization.canAccessEventLogs && organization.useEvents) ||
(organization.isOwner && (isBreadcrumbEventLogsEnabled$ | async))
"
*ngIf="organization.canAccessEventLogs || organization.isOwner"
></bit-nav-item>
<bit-nav-item
[text]="'reports' | i18n"
@@ -102,7 +99,7 @@
<bit-nav-item
[text]="'policies' | i18n"
route="settings/policies"
*ngIf="canShowPoliciesTab$ | async"
*ngIf="organization.canManagePolicies"
></bit-nav-item>
<bit-nav-item
[text]="'twoStepLogin' | i18n"

View File

@@ -68,9 +68,7 @@ export class OrganizationLayoutComponent implements OnInit {
hideNewOrgButton$: Observable<boolean>;
organizationIsUnmanaged$: Observable<boolean>;
protected isBreadcrumbEventLogsEnabled$: Observable<boolean>;
protected showSponsoredFamiliesDropdown$: Observable<boolean>;
protected canShowPoliciesTab$: Observable<boolean>;
protected paymentDetailsPageData$: Observable<{
route: string;
@@ -94,9 +92,6 @@ export class OrganizationLayoutComponent implements OnInit {
) {}
async ngOnInit() {
this.isBreadcrumbEventLogsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.PM12276_BreadcrumbEventLogs,
);
document.body.classList.remove("layout_frontend");
this.organization$ = this.route.params.pipe(
@@ -141,18 +136,6 @@ export class OrganizationLayoutComponent implements OnInit {
this.integrationPageEnabled$ = this.organization$.pipe(map((org) => org.canAccessIntegrations));
this.canShowPoliciesTab$ = this.organization$.pipe(
switchMap((organization) =>
this.organizationBillingService
.isBreadcrumbingPoliciesEnabled$(organization)
.pipe(
map(
(isBreadcrumbingEnabled) => isBreadcrumbingEnabled || organization.canManagePolicies,
),
),
),
);
this.paymentDetailsPageData$ = this.configService
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
.pipe(

View File

@@ -1,4 +1,4 @@
@let usePlaceHolderEvents = !organization?.useEvents && (isBreadcrumbEventLogsEnabled$ | async);
@let usePlaceHolderEvents = !organization?.useEvents;
<app-header>
<span
bitBadge

View File

@@ -19,7 +19,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { EventSystemUser } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EventResponse } from "@bitwarden/common/models/response/event.response";
import { EventView } from "@bitwarden/common/models/view/event.view";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -62,10 +61,6 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe
private orgUsersUserIdMap = new Map<string, any>();
readonly ProductTierType = ProductTierType;
protected isBreadcrumbEventLogsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.PM12276_BreadcrumbEventLogs,
);
constructor(
private apiService: ApiService,
private route: ActivatedRoute,

View File

@@ -1,20 +1,7 @@
<app-header>
@let organization = organization$ | async;
@if (isBreadcrumbingEnabled$ | async) {
<button
bitBadge
class="!tw-align-middle"
(click)="changePlan(organization)"
slot="title-suffix"
type="button"
variant="primary"
>
{{ "upgrade" | i18n }}
</button>
}
</app-header>
<app-header></app-header>
<bit-container>
@let organization = organization$ | async;
@if (loading) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"

View File

@@ -2,8 +2,8 @@
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom, lastValueFrom, map, Observable, switchMap } from "rxjs";
import { first } from "rxjs/operators";
import { firstValueFrom, lastValueFrom, Observable } from "rxjs";
import { first, map } from "rxjs/operators";
import {
getOrganizationById,
@@ -14,18 +14,11 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import {
ChangePlanDialogResultType,
openChangePlanDialog,
} from "@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component";
import { All } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model";
import { PolicyListService } from "../../core/policy-list.service";
import { BasePolicy } from "../policies";
import { CollectionDialogTabType } from "../shared/components/collection-dialog";
import { PolicyEditComponent, PolicyEditDialogResult } from "./policy-edit.component";
@@ -38,19 +31,17 @@ export class PoliciesComponent implements OnInit {
loading = true;
organizationId: string;
policies: BasePolicy[];
protected organization$: Observable<Organization>;
organization$: Observable<Organization>;
private orgPolicies: PolicyResponse[];
protected policiesEnabledMap: Map<PolicyType, boolean> = new Map<PolicyType, boolean>();
protected isBreadcrumbingEnabled$: Observable<boolean>;
constructor(
private route: ActivatedRoute,
private accountService: AccountService,
private organizationService: OrganizationService,
private accountService: AccountService,
private policyApiService: PolicyApiServiceAbstraction,
private policyListService: PolicyListService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private dialogService: DialogService,
protected configService: ConfigService,
) {}
@@ -62,9 +53,11 @@ export class PoliciesComponent implements OnInit {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
this.organization$ = this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId));
this.policies = this.policyListService.getPolicies();
await this.load();
@@ -100,11 +93,7 @@ export class PoliciesComponent implements OnInit {
this.orgPolicies.forEach((op) => {
this.policiesEnabledMap.set(op.type, op.enabled);
});
this.isBreadcrumbingEnabled$ = this.organization$.pipe(
switchMap((organization) =>
this.organizationBillingService.isBreadcrumbingPoliciesEnabled$(organization),
),
);
this.loading = false;
}
@@ -117,34 +106,8 @@ export class PoliciesComponent implements OnInit {
});
const result = await lastValueFrom(dialogRef.closed);
switch (result) {
case PolicyEditDialogResult.Saved:
await this.load();
break;
case PolicyEditDialogResult.UpgradePlan:
await this.changePlan(await firstValueFrom(this.organization$));
break;
if (result === PolicyEditDialogResult.Saved) {
await this.load();
}
}
protected readonly CollectionDialogTabType = CollectionDialogTabType;
protected readonly All = All;
protected async changePlan(organization: Organization) {
const reference = openChangePlanDialog(this.dialogService, {
data: {
organizationId: organization.id,
subscription: null,
productTierType: organization.productTierType,
},
});
const result = await lastValueFrom(reference.closed);
if (result === ChangePlanDialogResultType.Closed) {
return;
}
await this.load();
}
}

View File

@@ -1,17 +1,5 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading" [title]="'editPolicy' | i18n" [subtitle]="policy.name | i18n">
<ng-container bitDialogTitle>
<button
bitBadge
class="!tw-align-middle"
(click)="upgradePlan()"
*ngIf="isBreadcrumbingEnabled$ | async"
type="button"
variant="primary"
>
{{ "planNameEnterprise" | i18n }}
</button>
</ng-container>
<ng-container bitDialogContent>
<div *ngIf="loading">
<i
@@ -30,7 +18,6 @@
</ng-container>
<ng-container bitDialogFooter>
<button
*ngIf="!(isBreadcrumbingEnabled$ | async); else breadcrumbing"
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
@@ -39,11 +26,6 @@
>
{{ "save" | i18n }}
</button>
<ng-template #breadcrumbing>
<button bitButton buttonType="primary" bitFormButton type="button" (click)="upgradePlan()">
{{ "upgrade" | i18n }}
</button>
</ng-template>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>

View File

@@ -9,20 +9,12 @@ import {
ViewContainerRef,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { map, Observable, switchMap } from "rxjs";
import { Observable, map } from "rxjs";
import {
getOrganizationById,
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 { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
DIALOG_DATA,
@@ -45,7 +37,6 @@ export type PolicyEditDialogData = {
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum PolicyEditDialogResult {
Saved = "saved",
UpgradePlan = "upgrade-plan",
}
@Component({
selector: "app-policy-edit",
@@ -66,22 +57,15 @@ export class PolicyEditComponent implements AfterViewInit {
formGroup = this.formBuilder.group({
enabled: [this.enabled],
});
protected organization$: Observable<Organization>;
protected isBreadcrumbingEnabled$: Observable<boolean>;
constructor(
@Inject(DIALOG_DATA) protected data: PolicyEditDialogData,
private accountService: AccountService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationService: OrganizationService,
private i18nService: I18nService,
private cdr: ChangeDetectorRef,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<PolicyEditDialogResult>,
private toastService: ToastService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
) {}
get policy(): BasePolicy {
return this.data.policy;
}
@@ -115,16 +99,6 @@ export class PolicyEditComponent implements AfterViewInit {
throw e;
}
}
this.organization$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.organizationService.organizations$(userId)),
getOrganizationById(this.data.organizationId),
);
this.isBreadcrumbingEnabled$ = this.organization$.pipe(
switchMap((organization) =>
this.organizationBillingService.isBreadcrumbingPoliciesEnabled$(organization),
),
);
}
submit = async () => {
@@ -154,8 +128,4 @@ export class PolicyEditComponent implements AfterViewInit {
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
return dialogService.open<PolicyEditDialogResult>(PolicyEditComponent, config);
};
protected upgradePlan(): void {
this.dialogRef.close(PolicyEditDialogResult.UpgradePlan);
}
}

View File

@@ -1,13 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { inject, NgModule } from "@angular/core";
import { CanMatchFn, RouterModule, Routes } from "@angular/router";
import { map } from "rxjs";
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { canAccessReportingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
// eslint-disable-next-line no-restricted-imports
import { ExposedPasswordsReportComponent } from "../../../dirt/reports/pages/organizations/exposed-passwords-report.component";
@@ -26,11 +23,6 @@ import { EventsComponent } from "../manage/events.component";
import { ReportsHomeComponent } from "./reports-home.component";
const breadcrumbEventLogsPermission$: CanMatchFn = () =>
inject(ConfigService)
.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs)
.pipe(map((breadcrumbEventLogs) => breadcrumbEventLogs === true));
const routes: Routes = [
{
path: "",
@@ -92,24 +84,10 @@ const routes: Routes = [
},
],
},
// Event routing is temporarily duplicated
{
path: "events",
component: EventsComponent,
canMatch: [breadcrumbEventLogsPermission$], // if this matches, the flag is ON
canActivate: [
organizationPermissionsGuard(
(org) => (org.canAccessEventLogs && org.useEvents) || org.isOwner,
),
],
data: {
titleId: "eventLogs",
},
},
{
path: "events",
component: EventsComponent,
canActivate: [organizationPermissionsGuard((org) => org.canAccessEventLogs)],
canActivate: [organizationPermissionsGuard((org) => org.canAccessEventLogs || org.isOwner)],
data: {
titleId: "eventLogs",
},

View File

@@ -1,10 +1,8 @@
import { NgModule, inject } from "@angular/core";
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { map } from "rxjs";
import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { organizationPermissionsGuard } from "../../organizations/guards/org-permissions.guard";
import { organizationRedirectGuard } from "../../organizations/guards/org-redirect.guard";
@@ -43,14 +41,7 @@ const routes: Routes = [
{
path: "policies",
component: PoliciesComponent,
canActivate: [
organizationPermissionsGuard((o: Organization) => {
const organizationBillingService = inject(OrganizationBillingServiceAbstraction);
return organizationBillingService
.isBreadcrumbingPoliciesEnabled$(o)
.pipe(map((isBreadcrumbingEnabled) => o.canManagePolicies || isBreadcrumbingEnabled));
}),
],
canActivate: [organizationPermissionsGuard((org) => org.canManagePolicies)],
data: {
titleId: "policies",
},

View File

@@ -34,7 +34,6 @@ import {
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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";
@@ -188,22 +187,16 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
await this.loadOrg(this.params.organizationId);
}
const isBreadcrumbEventLogsEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs),
this.organizationSelected.setAsyncValidators(
freeOrgCollectionLimitValidator(
this.organizations$,
this.collectionService
.encryptedCollections$(userId)
.pipe(map((collections) => collections ?? [])),
this.i18nService,
),
);
if (isBreadcrumbEventLogsEnabled) {
this.organizationSelected.setAsyncValidators(
freeOrgCollectionLimitValidator(
this.organizations$,
this.collectionService
.encryptedCollections$(userId)
.pipe(map((collections) => collections ?? [])),
this.i18nService,
),
);
this.formGroup.updateValueAndValidity();
}
this.formGroup.updateValueAndValidity();
this.organizationSelected.valueChanges
.pipe(

View File

@@ -42,7 +42,7 @@ export type TrialOrganizationType = Exclude<ProductTierType, ProductTierType.Fre
export interface OrganizationInfo {
name: string;
email: string;
type: TrialOrganizationType;
type: TrialOrganizationType | null;
}
export interface OrganizationCreatedEvent {

View File

@@ -6,7 +6,7 @@
[showClose]="false"
*ngIf="freeTrialData?.shownBanner"
>
{{ freeTrialData.message }}
{{ freeTrialData?.message }}
<a
bitLink
linkType="secondary"

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Location } from "@angular/common";
import { Component, OnDestroy } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@@ -47,21 +45,21 @@ import { FreeTrial } from "../../types/free-trial";
standalone: false,
})
export class OrganizationPaymentMethodComponent implements OnDestroy {
organizationId: string;
organizationId!: string;
isUnpaid = false;
accountCredit: number;
accountCredit?: number;
paymentSource?: PaymentSourceResponse;
subscriptionStatus?: string;
protected freeTrialData: FreeTrial;
organization: Organization;
organizationSubscriptionResponse: OrganizationSubscriptionResponse;
protected freeTrialData?: FreeTrial;
organization?: Organization;
organizationSubscriptionResponse?: OrganizationSubscriptionResponse;
loading = true;
protected readonly Math = Math;
launchPaymentModalAutomatically = false;
protected taxInformation: TaxInformation;
protected taxInformation?: TaxInformation;
constructor(
private activatedRoute: ActivatedRoute,
@@ -104,7 +102,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
.subscribe();
const state = this.router.getCurrentNavigation()?.extras?.state;
// incase the above state is undefined or null we use redundantState
// In case the above state is undefined or null, we use redundantState
const redundantState: any = location.getState();
const queryParam = this.activatedRoute.snapshot.queryParamMap.get(
"launchPaymentModalAutomatically",
@@ -116,10 +114,8 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically")
) {
this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically;
} else if (queryParam === "true") {
this.launchPaymentModalAutomatically = true;
} else {
this.launchPaymentModalAutomatically = false;
this.launchPaymentModalAutomatically = queryParam === "true";
}
}
ngOnDestroy(): void {
@@ -155,14 +151,21 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
this.paymentSource = paymentSource;
this.subscriptionStatus = subscriptionStatus;
this.taxInformation = taxInformation;
this.isUnpaid = this.subscriptionStatus === "unpaid";
if (this.organizationId) {
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
this.organizationId,
);
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (!userId) {
throw new Error("User ID is not found");
}
const organizationPromise = await firstValueFrom(
this.organizationService
.organizations$(userId)
@@ -173,15 +176,20 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
organizationSubscriptionPromise,
organizationPromise,
]);
if (!this.organization) {
throw new Error("Organization is not found");
}
if (!this.paymentSource) {
throw new Error("Payment source is not found");
}
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
this.organization,
this.organizationSubscriptionResponse,
paymentSource,
this.paymentSource,
);
}
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
// eslint-disable-next-line no-constant-binary-expression
this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false;
// If the flag `launchPaymentModalAutomatically` is set to true,
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
@@ -219,14 +227,14 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, {
data: {
organizationId: this.organizationId,
subscription: this.organizationSubscriptionResponse,
productTierType: this.organization?.productTierType,
subscription: this.organizationSubscriptionResponse!,
productTierType: this.organization!.productTierType,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) {
this.location.replaceState(this.location.path(), "", {});
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
if (this.launchPaymentModalAutomatically && !this.organization?.enabled) {
await this.syncService.fullSync(true);
}
this.launchPaymentModalAutomatically = false;
@@ -238,13 +246,14 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
await this.billingApiService.verifyOrganizationBankAccount(this.organizationId, request);
this.toastService.showToast({
variant: "success",
title: null,
title: "",
message: this.i18nService.t("verifiedBankAccount"),
});
};
protected get accountCreditHeaderText(): string {
const key = this.accountCredit <= 0 ? "accountBalance" : "accountCredit";
const hasAccountCredit = this.accountCredit && this.accountCredit > 0;
const key = hasAccountCredit ? "accountCredit" : "accountBalance";
return this.i18nService.t(key);
}
@@ -279,7 +288,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
if (!hasBillingAddress) {
this.toastService.showToast({
variant: "error",
title: null,
title: "",
message: this.i18nService.t("billingAddressRequiredToAddCredit"),
});
return false;

View File

@@ -24,7 +24,7 @@ import {
import { PaymentComponent } from "../payment/payment.component";
export interface AdjustPaymentDialogParams {
initialPaymentMethod?: PaymentMethodType;
initialPaymentMethod?: PaymentMethodType | null;
organizationId?: string;
productTier?: ProductTierType;
providerId?: string;

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Location } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
@@ -42,21 +40,21 @@ import {
export class PaymentMethodComponent implements OnInit, OnDestroy {
loading = false;
firstLoaded = false;
billing: BillingPaymentResponse;
org: OrganizationSubscriptionResponse;
sub: SubscriptionResponse;
billing?: BillingPaymentResponse;
org?: OrganizationSubscriptionResponse;
sub?: SubscriptionResponse;
paymentMethodType = PaymentMethodType;
organizationId: string;
organizationId?: string;
isUnpaid = false;
organization: Organization;
organization?: Organization;
verifyBankForm = this.formBuilder.group({
amount1: new FormControl<number>(null, [
amount1: new FormControl<number>(0, [
Validators.required,
Validators.max(99),
Validators.min(0),
]),
amount2: new FormControl<number>(null, [
amount2: new FormControl<number>(0, [
Validators.required,
Validators.max(99),
Validators.min(0),
@@ -64,7 +62,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
});
launchPaymentModalAutomatically = false;
protected freeTrialData: FreeTrial;
protected freeTrialData?: FreeTrial;
constructor(
protected apiService: ApiService,
@@ -84,7 +82,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
private configService: ConfigService,
) {
const state = this.router.getCurrentNavigation()?.extras?.state;
// incase the above state is undefined or null we use redundantState
// In case the above state is undefined or null, we use redundantState
const redundantState: any = location.getState();
if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) {
this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically;
@@ -129,17 +127,23 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
}
this.loading = true;
if (this.forOrganization) {
const billingPromise = this.organizationApiService.getBilling(this.organizationId);
const billingPromise = this.organizationApiService.getBilling(this.organizationId!);
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
this.organizationId,
this.organizationId!,
);
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (!userId) {
throw new Error("User ID is not found");
}
const organizationPromise = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId)),
.pipe(getOrganizationById(this.organizationId!)),
);
[this.billing, this.org, this.organization] = await Promise.all([
@@ -171,14 +175,16 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
};
addCredit = async () => {
const dialogRef = openAddCreditDialog(this.dialogService, {
data: {
organizationId: this.organizationId,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AddCreditDialogResult.Added) {
await this.load();
if (this.forOrganization) {
const dialogRef = openAddCreditDialog(this.dialogService, {
data: {
organizationId: this.organizationId!,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AddCreditDialogResult.Added) {
await this.load();
}
}
};
@@ -194,7 +200,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
if (result === AdjustPaymentDialogResultType.Submitted) {
this.location.replaceState(this.location.path(), "", {});
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
if (this.launchPaymentModalAutomatically && !this.organization?.enabled) {
await this.syncService.fullSync(true);
}
this.launchPaymentModalAutomatically = false;
@@ -208,18 +214,22 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
}
const request = new VerifyBankRequest();
request.amount1 = this.verifyBankForm.value.amount1;
request.amount2 = this.verifyBankForm.value.amount2;
await this.organizationApiService.verifyBank(this.organizationId, request);
request.amount1 = this.verifyBankForm.value.amount1!;
request.amount2 = this.verifyBankForm.value.amount2!;
await this.organizationApiService.verifyBank(this.organizationId!, request);
this.toastService.showToast({
variant: "success",
title: null,
title: "",
message: this.i18nService.t("verifiedBankAccount"),
});
await this.load();
};
determineOrgsWithUpcomingPaymentIssues() {
if (!this.organization || !this.org || !this.billing) {
throw new Error("Organization, organization subscription, or billing is not defined");
}
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
this.organization,
this.org,

View File

@@ -37,7 +37,7 @@
bitButton
buttonType="primary"
[disabled]="orgInfoFormGroup.controls.name.invalid"
[loading]="loading && (trialPaymentOptional$ | async)"
[loading]="loading && (trialPaymentOptional$ | async)!"
(click)="orgNameEntrySubmit()"
>
{{
@@ -55,8 +55,8 @@
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
name: orgInfoFormGroup.value.name,
email: orgInfoFormGroup.value.billingEmail,
name: orgInfoFormGroup.value.name!,
email: orgInfoFormGroup.value.billingEmail!,
type: trialOrganizationType,
}"
[subscriptionProduct]="

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { StepperSelectionEvent } from "@angular/cdk/stepper";
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
@@ -50,15 +48,15 @@ export type InitiationPath =
standalone: false,
})
export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
@ViewChild("stepper", { static: false }) verticalStepper!: VerticalStepperComponent;
inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAccountRegistration;
initializing = true;
/** Password Manager or Secrets Manager */
product: ProductType;
product?: ProductType;
/** The tier of product being subscribed to */
productTier: ProductTierType;
productTier!: ProductTierType;
/** Product types that display steppers for Password Manager */
stepperProductTypes: ProductTierType[] = [
ProductTierType.Teams,
@@ -79,16 +77,16 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
orgId = "";
orgLabel = "";
billingSubLabel = "";
enforcedPolicyOptions: MasterPasswordPolicyOptions;
enforcedPolicyOptions?: MasterPasswordPolicyOptions;
/** User's email address associated with the trial */
email = "";
/** Token from the backend associated with the email verification */
emailVerificationToken: string;
emailVerificationToken?: string;
loading = false;
productTierValue: number;
productTierValue?: ProductTierType;
trialLength: number;
trialLength!: number;
orgInfoFormGroup = this.formBuilder.group({
name: ["", { validators: [Validators.required, Validators.maxLength(50)], updateOn: "change" }],
@@ -132,7 +130,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
// Show email validation toast when coming from email
if (qParams.fromEmail && qParams.fromEmail === "true") {
this.toastService.showToast({
title: null,
title: "",
message: this.i18nService.t("emailVerifiedV2"),
variant: "success",
});
@@ -172,9 +170,15 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
});
const invite = await this.organizationInviteService.getOrganizationInvite();
let policies: Policy[] | null = null;
let policies: Policy[] | undefined | null = null;
if (invite != null) {
if (
invite != null &&
invite.organizationId &&
invite.token &&
invite.email &&
invite.organizationUserId
) {
try {
policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
@@ -218,7 +222,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
if (event.selectedIndex === 1 && this.orgInfoFormGroup.controls.name.value === "") {
this.orgInfoSubLabel = this.planInfoLabel;
} else if (event.previouslySelectedIndex === 1) {
this.orgInfoSubLabel = this.orgInfoFormGroup.controls.name.value;
this.orgInfoSubLabel = this.orgInfoFormGroup.controls.name.value!;
}
}
@@ -260,8 +264,11 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
}
const organization: OrganizationInformation = {
name: this.orgInfoFormGroup.value.name,
billingEmail: this.orgInfoFormGroup.value.billingEmail,
name: this.orgInfoFormGroup.value.name == null ? "" : this.orgInfoFormGroup.value.name,
billingEmail:
this.orgInfoFormGroup.value.billingEmail == null
? ""
: this.orgInfoFormGroup.value.billingEmail,
initiationPath: trialInitiationPath,
};
@@ -326,7 +333,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
}
}
get trialOrganizationType(): TrialOrganizationType {
get trialOrganizationType(): TrialOrganizationType | null {
if (this.productTier === ProductTierType.Free) {
return null;
}
@@ -352,8 +359,12 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
const response = await this.organizationBillingService.startFree({
organization: {
name: this.orgInfoFormGroup.value.name,
billingEmail: this.orgInfoFormGroup.value.billingEmail,
name: this.orgInfoFormGroup.value.name == null ? "" : this.orgInfoFormGroup.value.name,
billingEmail:
this.orgInfoFormGroup.value.billingEmail == null
? ""
: this.orgInfoFormGroup.value.billingEmail,
initiationPath: "Password Manager trial from marketing website",
},
plan: {
type: 0,
@@ -405,11 +416,11 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
await this.loginStrategyService.logIn(credentials);
}
finishRegistration(passwordInputResult: PasswordInputResult) {
async finishRegistration(passwordInputResult: PasswordInputResult) {
this.submitting = true;
return this.registrationFinishService
.finishRegistration(this.email, passwordInputResult, this.emailVerificationToken)
.catch((e) => {
.catch((e: unknown): null => {
this.validationService.showError(e);
this.submitting = false;
return null;

View File

@@ -0,0 +1,18 @@
import { Component } from "@angular/core";
import { ConfirmKeyConnectorDomainComponent as BaseConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
import { RouterService } from "@bitwarden/web-vault/app/core";
@Component({
selector: "app-confirm-key-connector-domain",
template: ` <confirm-key-connector-domain [onBeforeNavigation]="onBeforeNavigation" /> `,
standalone: true,
imports: [BaseConfirmKeyConnectorDomainComponent],
})
export class ConfirmKeyConnectorDomainComponent {
constructor(private routerService: RouterService) {}
onBeforeNavigation = async () => {
await this.routerService.getAndClearLoginRedirectUrl();
};
}

View File

@@ -67,6 +67,7 @@ import { freeTrialTextResolver } from "./billing/trial-initiation/complete-trial
import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component";
import { RouteDataProperties } from "./core";
import { ReportsModule } from "./dirt/reports";
import { ConfirmKeyConnectorDomainComponent } from "./key-management/key-connector/confirm-key-connector-domain.component";
import { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component";
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
import { UserLayoutComponent } from "./layouts/user-layout.component";
@@ -511,6 +512,17 @@ const routes: Routes = [
titleId: "removeMasterPassword",
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
path: "confirm-key-connector-domain",
component: ConfirmKeyConnectorDomainComponent,
canActivate: [],
data: {
pageTitle: {
key: "confirmKeyConnectorDomain",
},
titleId: "confirmKeyConnectorDomain",
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
path: "trial-initiation",
canActivate: [unauthGuardFn()],

View File

@@ -13,7 +13,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -218,28 +217,22 @@ export class VaultHeaderComponent {
}
async addCollection(): Promise<void> {
const isBreadcrumbEventLogsEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs),
const organization = this.organizations?.find(
(org) => org.productTierType === ProductTierType.Free,
);
if (isBreadcrumbEventLogsEnabled) {
const organization = this.organizations?.find(
(org) => org.productTierType === ProductTierType.Free,
);
if (this.organizations?.length == 1 && !!organization) {
const collections = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.collectionAdminService.collectionAdminViews$(organization.id, userId),
),
if (this.organizations?.length == 1 && !!organization) {
const collections = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.collectionAdminService.collectionAdminViews$(organization.id, userId),
),
);
if (collections.length === organization.maxCollections) {
await this.showFreeOrgUpgradeDialog(organization);
return;
}
),
);
if (collections.length === organization.maxCollections) {
await this.showFreeOrgUpgradeDialog(organization);
return;
}
}

View File

@@ -11166,6 +11166,9 @@
"example": "92873837267"
}
}
},
"confirmKeyConnectorDomain": {
"message": "Confirm Key Connector domain"
}
}

View File

@@ -0,0 +1,22 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("../../tsconfig.base");
const sharedConfig = require("../../libs/shared/jest.config.angular");
/** @type {import('jest').Config} */
module.exports = {
...sharedConfig,
setupFilesAfterEnv: ["../../apps/browser/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(
{
"@bitwarden/common/spec": ["libs/common/spec"],
"@bitwarden/common": ["libs/common/src/*"],
"@bitwarden/admin-console/common": ["libs/admin-console/src/common"],
...(compilerOptions?.paths ?? {}),
},
{
prefix: "<rootDir>/../../",
},
),
};

View File

@@ -0,0 +1,9 @@
import OssMainBackground from "@bitwarden/browser/background/main.background";
export default class MainBackground {
private ossMain = new OssMainBackground();
async bootstrap() {
await this.ossMain.bootstrap();
}
}

View File

@@ -0,0 +1,7 @@
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import MainBackground from "../background/main.background";
const logService = new ConsoleLogService(false);
const bitwardenMain = ((self as any).bitwardenMain = new MainBackground());
bitwardenMain.bootstrap().catch((error) => logService.error(error));

View File

@@ -0,0 +1,10 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
const routes: Routes = [];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}

View File

@@ -0,0 +1,14 @@
import { Component, OnInit } from "@angular/core";
import { AppComponent as BaseAppComponent } from "@bitwarden/browser/popup/app.component";
@Component({
selector: "app-root",
templateUrl: "../../../../apps/browser/src/popup/app.component.html",
standalone: false,
})
export class AppComponent extends BaseAppComponent implements OnInit {
ngOnInit() {
return super.ngOnInit();
}
}

View File

@@ -0,0 +1,39 @@
import { OverlayModule } from "@angular/cdk/overlay";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
// import { AppRoutingAnimationsModule } from "@bitwarden/browser/popup/app-routing-animations";
import { AppRoutingModule as OssRoutingModule } from "@bitwarden/browser/popup/app-routing.module";
import { AppModule as OssModule } from "@bitwarden/browser/popup/app.module";
// import { WildcardRoutingModule } from "@bitwarden/browser/popup/wildcard-routing.module";
import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
/**
* This is the AppModule for the commercial version of Bitwarden.
* `apps/browser/app.module.ts` contains the OSS version.
*
* You probably do not want to modify this file. Consider editing `oss.module.ts` instead.
*/
@NgModule({
imports: [
CommonModule,
OverlayModule,
OssModule,
JslibModule,
// BrowserAnimationsModule,
// FormsModule,
// ReactiveFormsModule,
// CoreModule,
// DragDropModule,
AppRoutingModule,
OssRoutingModule,
RouterModule,
// WildcardRoutingModule, // Needs to be last to catch all non-existing routes
],
declarations: [AppComponent],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -0,0 +1,26 @@
import { enableProdMode } from "@angular/core";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { PopupSizeService } from "@bitwarden/browser/platform/popup/layout/popup-size.service";
import { BrowserPlatformUtilsService } from "@bitwarden/browser/platform/services/platform-utils/browser-platform-utils.service";
import { AppModule } from "./app.module";
import "@bitwarden/browser/popup/scss";
// We put these first to minimize the delay in window changing.
PopupSizeService.initBodyWidthFromLocalStorage();
// Should be removed once we deprecate support for Safari 16.0 and older. See Jira ticket [PM-1861]
if (BrowserPlatformUtilsService.shouldApplySafariHeightFix(window)) {
document.documentElement.classList.add("safari_height_fix");
}
if (process.env.ENV === "production") {
enableProdMode();
}
function init() {
void platformBrowserDynamic().bootstrapModule(AppModule);
}
init();

View File

@@ -0,0 +1,20 @@
{
"extends": "../../apps/browser/tsconfig",
"include": [
"src",
"../../apps/browser/src/**/*.d.ts",
"../../apps/browser/src/autofill/content/*.ts",
"../../apps/browser/src/autofill/fido2/content/*.ts",
"../../apps/browser/src/autofill/notification/bar.ts",
"../../apps/browser/src/autofill/overlay/inline-menu/**/*.ts",
"../../apps/browser/src/platform/ipc/content/*.ts",
"../../apps/browser/src/platform/offscreen-document/offscreen-document.ts",
"../../apps/browser/src/popup/polyfills.ts",
"../../apps/browser/src/vault/content/*.ts",
"../../libs/common/src/autofill/constants",
"../../libs/common/custom-matchers.d.ts"
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"isolatedModules": true,
"emitDecoratorMetadata": false
},
"files": ["../../apps/browser/test.setup.ts"]
}

View File

@@ -0,0 +1,13 @@
const { buildConfig } = require("../../apps/browser/webpack.base");
module.exports = buildConfig({
configName: "Commercial",
popup: {
entry: "../../bitwarden_license/bit-browser/src/popup/main.ts",
entryModule: "../../bitwarden_license/bit-browser/src/popup/app.module#AppModule",
},
background: {
entry: "../../bitwarden_license/bit-browser/src/platform/background.ts",
},
tsConfig: "../../bitwarden_license/bit-browser/tsconfig.json",
});

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