From 2810f2aaafa66f189cd35a152635830dda7ba92f Mon Sep 17 00:00:00 2001 From: Github Actions Date: Tue, 1 Jul 2025 17:02:17 +0000 Subject: [PATCH 01/19] Bumped client version(s) --- apps/browser/package.json | 2 +- apps/browser/src/manifest.json | 2 +- apps/browser/src/manifest.v3.json | 2 +- package-lock.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/browser/package.json b/apps/browser/package.json index 9b6d0174b0f..16e460a9025 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2025.6.0", + "version": "2025.6.1", "scripts": { "build": "npm run build:chrome", "build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 9f6529643c4..c46674083b2 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.6.0", + "version": "2025.6.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index bf5c4e439b9..6d38a5880d5 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.6.0", + "version": "2025.6.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/package-lock.json b/package-lock.json index 176aa40a650..9d2eded4c7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -197,7 +197,7 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2025.6.0" + "version": "2025.6.1" }, "apps/cli": { "name": "@bitwarden/cli", From 3ae5e063a73fb179b8f7430e7db1c01aa98802bf Mon Sep 17 00:00:00 2001 From: Github Actions Date: Tue, 1 Jul 2025 17:11:29 +0000 Subject: [PATCH 02/19] Bumped client version(s) --- apps/cli/package.json | 2 +- package-lock.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index ea94314c641..520140a676d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/cli", "description": "A secure and free password manager for all of your devices.", - "version": "2025.6.0", + "version": "2025.6.1", "keywords": [ "bitwarden", "password", diff --git a/package-lock.json b/package-lock.json index 9d2eded4c7a..a6b965f89f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -201,7 +201,7 @@ }, "apps/cli": { "name": "@bitwarden/cli", - "version": "2025.6.0", + "version": "2025.6.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@koa/multer": "3.1.0", From 4cb80b4a037c075cfc2b77bc73f35575131d9ec7 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:47:02 -0400 Subject: [PATCH 03/19] Platform logging lib (#15338) * Add Platform Logging Lib * Move console log spec and test util back into libs/common * Fix ConsoleLogServer re-export * Fix types error --- .github/CODEOWNERS | 1 + .../src/platform/abstractions/log.service.ts | 10 +--- .../src/platform/enums/log-level-type.enum.ts | 9 +-- .../services/console-log.service.spec.ts | 4 +- .../platform/services/console-log.service.ts | 60 +------------------ libs/logging/README.md | 5 ++ libs/logging/eslint.config.mjs | 3 + libs/logging/jest.config.js | 10 ++++ libs/logging/package.json | 11 ++++ libs/logging/project.json | 33 ++++++++++ libs/logging/src/console-log.service.ts | 57 ++++++++++++++++++ libs/logging/src/index.ts | 3 + libs/logging/src/log-level.ts | 8 +++ libs/logging/src/log.service.ts | 9 +++ libs/logging/src/logging.spec.ts | 8 +++ libs/logging/tsconfig.json | 13 ++++ libs/logging/tsconfig.lib.json | 10 ++++ libs/logging/tsconfig.spec.json | 16 +++++ package-lock.json | 8 +++ tsconfig.base.json | 1 + 20 files changed, 201 insertions(+), 78 deletions(-) create mode 100644 libs/logging/README.md create mode 100644 libs/logging/eslint.config.mjs create mode 100644 libs/logging/jest.config.js create mode 100644 libs/logging/package.json create mode 100644 libs/logging/project.json create mode 100644 libs/logging/src/console-log.service.ts create mode 100644 libs/logging/src/index.ts create mode 100644 libs/logging/src/log-level.ts create mode 100644 libs/logging/src/log.service.ts create mode 100644 libs/logging/src/logging.spec.ts create mode 100644 libs/logging/tsconfig.json create mode 100644 libs/logging/tsconfig.lib.json create mode 100644 libs/logging/tsconfig.spec.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index db60ad6a93b..590887b3cad 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -91,6 +91,7 @@ libs/common/spec @bitwarden/team-platform-dev libs/common/src/state-migrations @bitwarden/team-platform-dev libs/platform @bitwarden/team-platform-dev libs/storage-core @bitwarden/team-platform-dev +libs/logging @bitwarden/team-platform-dev libs/storage-test-utils @bitwarden/team-platform-dev # Web utils used across app and connectors apps/web/src/utils/ @bitwarden/team-platform-dev diff --git a/libs/common/src/platform/abstractions/log.service.ts b/libs/common/src/platform/abstractions/log.service.ts index d77a4f69906..c540f1a2b8f 100644 --- a/libs/common/src/platform/abstractions/log.service.ts +++ b/libs/common/src/platform/abstractions/log.service.ts @@ -1,9 +1 @@ -import { LogLevelType } from "../enums/log-level-type.enum"; - -export abstract class LogService { - abstract debug(message?: any, ...optionalParams: any[]): void; - abstract info(message?: any, ...optionalParams: any[]): void; - abstract warning(message?: any, ...optionalParams: any[]): void; - abstract error(message?: any, ...optionalParams: any[]): void; - abstract write(level: LogLevelType, message?: any, ...optionalParams: any[]): void; -} +export { LogService } from "@bitwarden/logging"; diff --git a/libs/common/src/platform/enums/log-level-type.enum.ts b/libs/common/src/platform/enums/log-level-type.enum.ts index b5f84467d6e..024c71c9f97 100644 --- a/libs/common/src/platform/enums/log-level-type.enum.ts +++ b/libs/common/src/platform/enums/log-level-type.enum.ts @@ -1,8 +1 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum LogLevelType { - Debug, - Info, - Warning, - Error, -} +export { LogLevel as LogLevelType } from "@bitwarden/logging"; diff --git a/libs/common/src/platform/services/console-log.service.spec.ts b/libs/common/src/platform/services/console-log.service.spec.ts index 508ca4eb327..e73aed5f3b5 100644 --- a/libs/common/src/platform/services/console-log.service.spec.ts +++ b/libs/common/src/platform/services/console-log.service.spec.ts @@ -1,6 +1,6 @@ -import { interceptConsole, restoreConsole } from "../../../spec"; +import { ConsoleLogService } from "@bitwarden/logging"; -import { ConsoleLogService } from "./console-log.service"; +import { interceptConsole, restoreConsole } from "../../../spec"; describe("ConsoleLogService", () => { const error = new Error("this is an error"); diff --git a/libs/common/src/platform/services/console-log.service.ts b/libs/common/src/platform/services/console-log.service.ts index cb6554e2aa2..6d55614757b 100644 --- a/libs/common/src/platform/services/console-log.service.ts +++ b/libs/common/src/platform/services/console-log.service.ts @@ -1,59 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { LogService as LogServiceAbstraction } from "../abstractions/log.service"; -import { LogLevelType } from "../enums/log-level-type.enum"; - -export class ConsoleLogService implements LogServiceAbstraction { - protected timersMap: Map = new Map(); - - constructor( - protected isDev: boolean, - protected filter: (level: LogLevelType) => boolean = null, - ) {} - - debug(message?: any, ...optionalParams: any[]) { - if (!this.isDev) { - return; - } - this.write(LogLevelType.Debug, message, ...optionalParams); - } - - info(message?: any, ...optionalParams: any[]) { - this.write(LogLevelType.Info, message, ...optionalParams); - } - - warning(message?: any, ...optionalParams: any[]) { - this.write(LogLevelType.Warning, message, ...optionalParams); - } - - error(message?: any, ...optionalParams: any[]) { - this.write(LogLevelType.Error, message, ...optionalParams); - } - - write(level: LogLevelType, message?: any, ...optionalParams: any[]) { - if (this.filter != null && this.filter(level)) { - return; - } - - switch (level) { - case LogLevelType.Debug: - // eslint-disable-next-line - console.log(message, ...optionalParams); - break; - case LogLevelType.Info: - // eslint-disable-next-line - console.log(message, ...optionalParams); - break; - case LogLevelType.Warning: - // eslint-disable-next-line - console.warn(message, ...optionalParams); - break; - case LogLevelType.Error: - // eslint-disable-next-line - console.error(message, ...optionalParams); - break; - default: - break; - } - } -} +export { ConsoleLogService } from "@bitwarden/logging"; diff --git a/libs/logging/README.md b/libs/logging/README.md new file mode 100644 index 00000000000..d2ef90cb3f9 --- /dev/null +++ b/libs/logging/README.md @@ -0,0 +1,5 @@ +# logging + +Owned by: platform + +Logging primitives diff --git a/libs/logging/eslint.config.mjs b/libs/logging/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/logging/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/logging/jest.config.js b/libs/logging/jest.config.js new file mode 100644 index 00000000000..a231d3bfce9 --- /dev/null +++ b/libs/logging/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "logging", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/logging", +}; diff --git a/libs/logging/package.json b/libs/logging/package.json new file mode 100644 index 00000000000..b9cfbe35eb0 --- /dev/null +++ b/libs/logging/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bitwarden/logging", + "version": "0.0.1", + "description": "Logging primitives", + "private": true, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0", + "author": "platform" +} diff --git a/libs/logging/project.json b/libs/logging/project.json new file mode 100644 index 00000000000..f2b5db313be --- /dev/null +++ b/libs/logging/project.json @@ -0,0 +1,33 @@ +{ + "name": "logging", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/logging/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/logging", + "main": "libs/logging/src/index.ts", + "tsConfig": "libs/logging/tsconfig.lib.json", + "assets": ["libs/logging/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/logging/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/logging/jest.config.js" + } + } + } +} diff --git a/libs/logging/src/console-log.service.ts b/libs/logging/src/console-log.service.ts new file mode 100644 index 00000000000..3a4ffe9ead1 --- /dev/null +++ b/libs/logging/src/console-log.service.ts @@ -0,0 +1,57 @@ +import { LogLevel } from "./log-level"; +import { LogService } from "./log.service"; + +export class ConsoleLogService implements LogService { + protected timersMap: Map = new Map(); + + constructor( + protected isDev: boolean, + protected filter: ((level: LogLevel) => boolean) | null = null, + ) {} + + debug(message?: any, ...optionalParams: any[]) { + if (!this.isDev) { + return; + } + this.write(LogLevel.Debug, message, ...optionalParams); + } + + info(message?: any, ...optionalParams: any[]) { + this.write(LogLevel.Info, message, ...optionalParams); + } + + warning(message?: any, ...optionalParams: any[]) { + this.write(LogLevel.Warning, message, ...optionalParams); + } + + error(message?: any, ...optionalParams: any[]) { + this.write(LogLevel.Error, message, ...optionalParams); + } + + write(level: LogLevel, message?: any, ...optionalParams: any[]) { + if (this.filter != null && this.filter(level)) { + return; + } + + switch (level) { + case LogLevel.Debug: + // eslint-disable-next-line + console.log(message, ...optionalParams); + break; + case LogLevel.Info: + // eslint-disable-next-line + console.log(message, ...optionalParams); + break; + case LogLevel.Warning: + // eslint-disable-next-line + console.warn(message, ...optionalParams); + break; + case LogLevel.Error: + // eslint-disable-next-line + console.error(message, ...optionalParams); + break; + default: + break; + } + } +} diff --git a/libs/logging/src/index.ts b/libs/logging/src/index.ts new file mode 100644 index 00000000000..8ce4b62cd3f --- /dev/null +++ b/libs/logging/src/index.ts @@ -0,0 +1,3 @@ +export { LogService } from "./log.service"; +export { LogLevel } from "./log-level"; +export { ConsoleLogService } from "./console-log.service"; diff --git a/libs/logging/src/log-level.ts b/libs/logging/src/log-level.ts new file mode 100644 index 00000000000..adf6c145c3d --- /dev/null +++ b/libs/logging/src/log-level.ts @@ -0,0 +1,8 @@ +// FIXME: update to use a const object instead of a typescript enum +// eslint-disable-next-line @bitwarden/platform/no-enums +export enum LogLevel { + Debug, + Info, + Warning, + Error, +} diff --git a/libs/logging/src/log.service.ts b/libs/logging/src/log.service.ts new file mode 100644 index 00000000000..a63ad47c07e --- /dev/null +++ b/libs/logging/src/log.service.ts @@ -0,0 +1,9 @@ +import { LogLevel } from "./log-level"; + +export abstract class LogService { + abstract debug(message?: any, ...optionalParams: any[]): void; + abstract info(message?: any, ...optionalParams: any[]): void; + abstract warning(message?: any, ...optionalParams: any[]): void; + abstract error(message?: any, ...optionalParams: any[]): void; + abstract write(level: LogLevel, message?: any, ...optionalParams: any[]): void; +} diff --git a/libs/logging/src/logging.spec.ts b/libs/logging/src/logging.spec.ts new file mode 100644 index 00000000000..04a057a156f --- /dev/null +++ b/libs/logging/src/logging.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("logging", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/logging/tsconfig.json b/libs/logging/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/logging/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/logging/tsconfig.lib.json b/libs/logging/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/logging/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/logging/tsconfig.spec.json b/libs/logging/tsconfig.spec.json new file mode 100644 index 00000000000..a19b962c49a --- /dev/null +++ b/libs/logging/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../..//dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts", + "src/intercept-console.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index a6b965f89f7..e9701cbd7ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -353,6 +353,10 @@ "version": "0.0.0", "license": "GPL-3.0" }, + "libs/logging": { + "version": "0.0.1", + "license": "GPL-3.0" + }, "libs/node": { "name": "@bitwarden/node", "version": "0.0.0", @@ -4583,6 +4587,10 @@ "resolved": "libs/key-management-ui", "link": true }, + "node_modules/@bitwarden/logging": { + "resolved": "libs/logging", + "link": true + }, "node_modules/@bitwarden/node": { "resolved": "libs/node", "link": true diff --git a/tsconfig.base.json b/tsconfig.base.json index b826d51e66e..c820306fd15 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -37,6 +37,7 @@ "@bitwarden/importer-ui": ["./libs/importer/src/components"], "@bitwarden/key-management": ["./libs/key-management/src"], "@bitwarden/key-management-ui": ["./libs/key-management-ui/src"], + "@bitwarden/logging": ["libs/logging/src"], "@bitwarden/node/*": ["./libs/node/src/*"], "@bitwarden/nx-plugin": ["libs/nx-plugin/src/index.ts"], "@bitwarden/platform": ["./libs/platform/src"], From 832e4b16f08be43aa60bda15623f6e129d45be91 Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:58:12 -0400 Subject: [PATCH 04/19] Org permission guards for accessing reports and displaying access intelligence (#15060) --- .../organizations/layouts/organization-layout.component.html | 2 +- .../organizations/organizations-routing.module.ts | 1 + .../access-intelligence/access-intelligence-routing.module.ts | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index f991678e834..d5e771d1b17 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -4,7 +4,7 @@ diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts index f63140a8b23..35659d05dce 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts @@ -79,6 +79,7 @@ const routes: Routes = [ }, { path: "access-intelligence", + canActivate: [organizationPermissionsGuard((org) => org.canAccessReports)], loadChildren: () => import("../../dirt/access-intelligence/access-intelligence.module").then( (m) => m.AccessIntelligenceModule, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence-routing.module.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence-routing.module.ts index 6df0f01bc8b..2e3c53d8d9f 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence-routing.module.ts @@ -9,7 +9,9 @@ const routes: Routes = [ { path: "", pathMatch: "full", redirectTo: "risk-insights" }, { path: "risk-insights", - canActivate: [organizationPermissionsGuard((org) => org.useRiskInsights)], + canActivate: [ + organizationPermissionsGuard((org) => org.useRiskInsights && org.canAccessReports), + ], component: RiskInsightsComponent, data: { titleId: "RiskInsights", From c9aa8498c7a66b792768768c116098eef77e1bab Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Tue, 1 Jul 2025 14:03:08 -0400 Subject: [PATCH 05/19] fix(desktop): save zoom level to state when it is adjusted (#15406) --- apps/desktop/src/main/window.main.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 4d9438b588d..5b81cf8140b 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -295,6 +295,15 @@ export class WindowMain { this.win.webContents.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0; }); + // Persist zoom changes immediately when user zooms in/out or resets zoom + // We can't depend on higher level web events (like close) to do this + // because locking the vault resets window state. + this.win.webContents.on("zoom-changed", async () => { + const newZoom = this.win.webContents.zoomFactor; + this.windowStates[mainWindowSizeKey].zoomFactor = newZoom; + await this.desktopSettingsService.setWindow(this.windowStates[mainWindowSizeKey]); + }); + if (this.windowStates[mainWindowSizeKey].isMaximized) { this.win.maximize(); } From 5eca3a591667766ff8dd42b67642ce960391004e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 1 Jul 2025 21:00:13 +0200 Subject: [PATCH 06/19] [PM-18809] Passkey: use ArrayBuffer instead of Uint8Array (#15092) * Passkey: use ArrayBuffer instead of Uint8Array to conform WebAuthn spec * ArrayBufferView generics was too modern for this project * Correctly update the types from Uint8arrays to ArrayBuffers * Fixed broken tests + bugs * Removed arrayBufferViewToArrayBuffer as it's not needed in this invocation paths --------- Co-authored-by: ozraru Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com> --- .../autofill/services/desktop-autofill.service.ts | 10 +++++----- .../fido2-authenticator.service.abstraction.ts | 10 +++++----- .../services/fido2/credential-id-utils.spec.ts | 4 ++-- .../services/fido2/credential-id-utils.ts | 15 +++++++++------ .../services/fido2/fido2-authenticator.service.ts | 2 +- .../services/fido2/fido2-client.service.ts | 4 ++-- .../src/platform/services/fido2/fido2-utils.ts | 4 ++-- 7 files changed, 26 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts index 7e60c6b8d76..5500bc58f5a 100644 --- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts @@ -209,7 +209,7 @@ export class DesktopAutofillService implements OnDestroy { } request.credentialId = Array.from( - parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId), + new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)), ); } @@ -336,12 +336,12 @@ export class DesktopAutofillService implements OnDestroy { response: Fido2AuthenticatorGetAssertionResult, ): autofill.PasskeyAssertionResponse { return { - userHandle: Array.from(response.selectedCredential.userHandle), + userHandle: Array.from(new Uint8Array(response.selectedCredential.userHandle)), rpId: request.rpId, - signature: Array.from(response.signature), + signature: Array.from(new Uint8Array(response.signature)), clientDataHash: request.clientDataHash, - authenticatorData: Array.from(response.authenticatorData), - credentialId: Array.from(response.selectedCredential.id), + authenticatorData: Array.from(new Uint8Array(response.authenticatorData)), + credentialId: Array.from(new Uint8Array(response.selectedCredential.id)), }; } diff --git a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts index 15655393362..fd3453198e6 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts @@ -70,7 +70,7 @@ export class Fido2AuthenticatorError extends Error { } export interface PublicKeyCredentialDescriptor { - id: Uint8Array; + id: ArrayBuffer; transports?: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; type: "public-key"; } @@ -155,9 +155,9 @@ export interface Fido2AuthenticatorGetAssertionParams { export interface Fido2AuthenticatorGetAssertionResult { selectedCredential: { - id: Uint8Array; - userHandle?: Uint8Array; + id: ArrayBuffer; + userHandle?: ArrayBuffer; }; - authenticatorData: Uint8Array; - signature: Uint8Array; + authenticatorData: ArrayBuffer; + signature: ArrayBuffer; } diff --git a/libs/common/src/platform/services/fido2/credential-id-utils.spec.ts b/libs/common/src/platform/services/fido2/credential-id-utils.spec.ts index 76e068ac01c..1f2217ccd63 100644 --- a/libs/common/src/platform/services/fido2/credential-id-utils.spec.ts +++ b/libs/common/src/platform/services/fido2/credential-id-utils.spec.ts @@ -9,7 +9,7 @@ describe("credential-id-utils", () => { new Uint8Array([ 0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07, 0xe7, - ]), + ]).buffer, ); }); @@ -20,7 +20,7 @@ describe("credential-id-utils", () => { new Uint8Array([ 0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07, 0xe7, - ]), + ]).buffer, ); }); diff --git a/libs/common/src/platform/services/fido2/credential-id-utils.ts b/libs/common/src/platform/services/fido2/credential-id-utils.ts index 685669f0da3..08ea33114f5 100644 --- a/libs/common/src/platform/services/fido2/credential-id-utils.ts +++ b/libs/common/src/platform/services/fido2/credential-id-utils.ts @@ -3,13 +3,13 @@ import { Fido2Utils } from "./fido2-utils"; import { guidToRawFormat } from "./guid-utils"; -export function parseCredentialId(encodedCredentialId: string): Uint8Array { +export function parseCredentialId(encodedCredentialId: string): ArrayBuffer { try { if (encodedCredentialId.startsWith("b64.")) { return Fido2Utils.stringToBuffer(encodedCredentialId.slice(4)); } - return guidToRawFormat(encodedCredentialId); + return guidToRawFormat(encodedCredentialId).buffer; } catch { return undefined; } @@ -18,13 +18,16 @@ export function parseCredentialId(encodedCredentialId: string): Uint8Array { /** * Compares two credential IDs for equality. */ -export function compareCredentialIds(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) { +export function compareCredentialIds(a: ArrayBuffer, b: ArrayBuffer): boolean { + if (a.byteLength !== b.byteLength) { return false; } - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) { + const viewA = new Uint8Array(a); + const viewB = new Uint8Array(b); + + for (let i = 0; i < viewA.length; i++) { + if (viewA[i] !== viewB[i]) { return false; } } diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts index bac1b218657..e560a77cc2e 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts @@ -514,7 +514,7 @@ async function getPrivateKeyFromFido2Credential( const keyBuffer = Fido2Utils.stringToBuffer(fido2Credential.keyValue); return await crypto.subtle.importKey( "pkcs8", - keyBuffer, + new Uint8Array(keyBuffer), { name: fido2Credential.keyAlgorithm, namedCurve: fido2Credential.keyCurve, diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.ts b/libs/common/src/platform/services/fido2/fido2-client.service.ts index 5d5f2a879cb..431585441a7 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.ts @@ -127,9 +127,9 @@ export class Fido2ClientService } const userId = Fido2Utils.stringToBuffer(params.user.id); - if (userId.length < 1 || userId.length > 64) { + if (userId.byteLength < 1 || userId.byteLength > 64) { this.logService?.warning( - `[Fido2Client] Invalid 'user.id' length: ${params.user.id} (${userId.length})`, + `[Fido2Client] Invalid 'user.id' length: ${params.user.id} (${userId.byteLength})`, ); throw new TypeError("Invalid 'user.id' length"); } diff --git a/libs/common/src/platform/services/fido2/fido2-utils.ts b/libs/common/src/platform/services/fido2/fido2-utils.ts index 6413eeade04..99e260f4a53 100644 --- a/libs/common/src/platform/services/fido2/fido2-utils.ts +++ b/libs/common/src/platform/services/fido2/fido2-utils.ts @@ -47,8 +47,8 @@ export class Fido2Utils { .replace(/=/g, ""); } - static stringToBuffer(str: string): Uint8Array { - return Fido2Utils.fromB64ToArray(Fido2Utils.fromUrlB64ToB64(str)); + static stringToBuffer(str: string): ArrayBuffer { + return Fido2Utils.fromB64ToArray(Fido2Utils.fromUrlB64ToB64(str)).buffer; } static bufferSourceToUint8Array(bufferSource: BufferSource): Uint8Array { From 172623e0505a044001bb5ffbec1f30e8061e4379 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 1 Jul 2025 15:59:11 -0400 Subject: [PATCH 07/19] [PM-20247] Initialize user-core library (#15029) * Initialize user-core library * Run `npm install` * Fix patched generator bug --- .github/CODEOWNERS | 1 + libs/common/src/types/guid.ts | 4 +++- libs/user-core/README.md | 6 ++++++ libs/user-core/eslint.config.mjs | 3 +++ libs/user-core/jest.config.js | 10 ++++++++++ libs/user-core/package.json | 10 ++++++++++ libs/user-core/project.json | 27 +++++++++++++++++++++++++++ libs/user-core/src/index.ts | 9 +++++++++ libs/user-core/tsconfig.json | 13 +++++++++++++ libs/user-core/tsconfig.lib.json | 10 ++++++++++ libs/user-core/tsconfig.spec.json | 10 ++++++++++ package-lock.json | 8 ++++++++ tsconfig.base.json | 1 + 13 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 libs/user-core/README.md create mode 100644 libs/user-core/eslint.config.mjs create mode 100644 libs/user-core/jest.config.js create mode 100644 libs/user-core/package.json create mode 100644 libs/user-core/project.json create mode 100644 libs/user-core/src/index.ts create mode 100644 libs/user-core/tsconfig.json create mode 100644 libs/user-core/tsconfig.lib.json create mode 100644 libs/user-core/tsconfig.spec.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 590887b3cad..9502a9c404d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,6 +18,7 @@ apps/cli/src/auth @bitwarden/team-auth-dev apps/desktop/src/auth @bitwarden/team-auth-dev apps/web/src/app/auth @bitwarden/team-auth-dev libs/auth @bitwarden/team-auth-dev +libs/user-core @bitwarden/team-auth-dev # web connectors used for auth apps/web/src/connectors @bitwarden/team-auth-dev bitwarden_license/bit-web/src/app/auth @bitwarden/team-auth-dev diff --git a/libs/common/src/types/guid.ts b/libs/common/src/types/guid.ts index bf891b55691..5edd34e4fc5 100644 --- a/libs/common/src/types/guid.ts +++ b/libs/common/src/types/guid.ts @@ -2,7 +2,9 @@ import { Opaque } from "type-fest"; export type Guid = Opaque; -export type UserId = Opaque; +// Convenience re-export of UserId from it's original location, any library that +// wants to be lower level than common should instead import it from user-core. +export { UserId } from "@bitwarden/user-core"; export type OrganizationId = Opaque; export type CollectionId = Opaque; export type ProviderId = Opaque; diff --git a/libs/user-core/README.md b/libs/user-core/README.md new file mode 100644 index 00000000000..57975746606 --- /dev/null +++ b/libs/user-core/README.md @@ -0,0 +1,6 @@ +# user-core + +Owned by: auth + +The very basic concept that constitutes a user, this needs to be very low level to facilitate +Platform keeping their own code low level. diff --git a/libs/user-core/eslint.config.mjs b/libs/user-core/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/user-core/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/user-core/jest.config.js b/libs/user-core/jest.config.js new file mode 100644 index 00000000000..e38a12f3eb5 --- /dev/null +++ b/libs/user-core/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "user-core", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/user-core", +}; diff --git a/libs/user-core/package.json b/libs/user-core/package.json new file mode 100644 index 00000000000..2251d2ceace --- /dev/null +++ b/libs/user-core/package.json @@ -0,0 +1,10 @@ +{ + "name": "@bitwarden/user-core", + "version": "0.0.0", + "description": "The very basic concept that constitutes a user, this needs to be very low level to facilitate Platform keeping their own code low level.", + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0", + "author": "auth" +} diff --git a/libs/user-core/project.json b/libs/user-core/project.json new file mode 100644 index 00000000000..60d5873208d --- /dev/null +++ b/libs/user-core/project.json @@ -0,0 +1,27 @@ +{ + "name": "user-core", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/user-core/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/user-core", + "main": "libs/user-core/src/index.ts", + "tsConfig": "libs/user-core/tsconfig.lib.json", + "assets": ["libs/user-core/*.md"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/user-core/jest.config.js", + "passWithNoTests": true + } + } + } +} diff --git a/libs/user-core/src/index.ts b/libs/user-core/src/index.ts new file mode 100644 index 00000000000..42e663c851a --- /dev/null +++ b/libs/user-core/src/index.ts @@ -0,0 +1,9 @@ +import { Opaque } from "type-fest"; + +/** + * The main identifier for a user. It is a string that should be in valid guid format. + * + * You should avoid `as UserId`-ing strings as much as possible and instead retrieve the {@see UserId} from + * a valid source instead. + */ +export type UserId = Opaque; diff --git a/libs/user-core/tsconfig.json b/libs/user-core/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/user-core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/user-core/tsconfig.lib.json b/libs/user-core/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/user-core/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/user-core/tsconfig.spec.json b/libs/user-core/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/user-core/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/package-lock.json b/package-lock.json index e9701cbd7ee..d7c23e0997b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -427,6 +427,10 @@ "version": "0.0.0", "license": "GPL-3.0" }, + "libs/user-core": { + "version": "0.0.0", + "license": "GPL-3.0" + }, "libs/vault": { "name": "@bitwarden/vault", "version": "0.0.0", @@ -4640,6 +4644,10 @@ "resolved": "libs/ui/common", "link": true }, + "node_modules/@bitwarden/user-core": { + "resolved": "libs/user-core", + "link": true + }, "node_modules/@bitwarden/vault": { "resolved": "libs/vault", "link": true diff --git a/tsconfig.base.json b/tsconfig.base.json index c820306fd15..c462ab97d37 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,6 +47,7 @@ "@bitwarden/storage-test-utils": ["libs/storage-test-utils/src/index.ts"], "@bitwarden/ui-common": ["./libs/ui/common/src"], "@bitwarden/ui-common/setup-jest": ["./libs/ui/common/src/setup-jest"], + "@bitwarden/user-core": ["libs/user-core/src/index.ts"], "@bitwarden/vault": ["./libs/vault/src"], "@bitwarden/vault-export-core": ["./libs/tools/export/vault-export/vault-export-core/src"], "@bitwarden/vault-export-ui": ["./libs/tools/export/vault-export/vault-export-ui/src"], From 586d91e81676b2be27fefa0321cbc2420f1012e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Tue, 1 Jul 2025 22:02:57 +0200 Subject: [PATCH 08/19] Redact SignalR token from logs (#15402) --- .../internal/signalr-connection.service.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts index 8bea98cb506..58d6311c668 100644 --- a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts @@ -31,22 +31,35 @@ export type TimeoutManager = { class SignalRLogger implements ILogger { constructor(private readonly logService: LogService) {} + redactMessage(message: string): string { + const ACCESS_TOKEN_TEXT = "access_token="; + // Redact the access token from the logs if it exists. + const accessTokenIndex = message.indexOf(ACCESS_TOKEN_TEXT); + if (accessTokenIndex !== -1) { + return message.substring(0, accessTokenIndex + ACCESS_TOKEN_TEXT.length) + "[REDACTED]"; + } + + return message; + } + log(logLevel: LogLevel, message: string): void { + const redactedMessage = `[SignalR] ${this.redactMessage(message)}`; + switch (logLevel) { case LogLevel.Critical: - this.logService.error(message); + this.logService.error(redactedMessage); break; case LogLevel.Error: - this.logService.error(message); + this.logService.error(redactedMessage); break; case LogLevel.Warning: - this.logService.warning(message); + this.logService.warning(redactedMessage); break; case LogLevel.Information: - this.logService.info(message); + this.logService.info(redactedMessage); break; case LogLevel.Debug: - this.logService.debug(message); + this.logService.debug(redactedMessage); break; } } From 3f7cb674afd1cb1452cf615d218eb638f2423418 Mon Sep 17 00:00:00 2001 From: Tyler <71953103+fntyler@users.noreply.github.com> Date: Tue, 1 Jul 2025 16:31:59 -0400 Subject: [PATCH 09/19] BRE-883 build(firefox): check file size (#15399) * build(firefox): check file size if building `firefox` or `firefox-mv3` * check if any file(s) exceeds 4M (megabytes) - If true, fail and provide basic message. * style: add clarity sytle: add error message * fix: relocate step ensure final step of source files before validating * test: add failure condition * fix: source file target directory * fix: test for failure condition * test: remove failure condition remove lines used for testing --- .github/workflows/build-browser.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index ea113f8b9a5..c75298a3e92 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -268,6 +268,29 @@ jobs: working-directory: browser-source/ run: npm link ../sdk-internal + - name: Check source file size + if: ${{ startsWith(matrix.name, 'firefox') }} + run: | + # Declare variable as indexed array + declare -a FILES + + # Search for source files that are greater than 4M + TARGET_DIR='./browser-source/apps/browser' + while IFS=' ' read -r RESULT; do + FILES+=("$RESULT") + done < <(find $TARGET_DIR -size +4M) + + # Validate results and provide messaging + if [[ ${#FILES[@]} -ne 0 ]]; then + echo "File(s) exceeds size limit: 4MB" + for FILE in ${FILES[@]}; do + echo "- $(du --si $FILE)" + done + echo "ERROR Firefox rejects extension uploads that contain files larger than 4MB" + # Invoke failure + exit 1 + fi + - name: Build extension run: npm run ${{ matrix.npm_command }} working-directory: browser-source/apps/browser From 616ac9a3c8e6ab613933236c3f5dee18d9bc9525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Tue, 1 Jul 2025 22:36:18 +0200 Subject: [PATCH 10/19] Fix Clippy 1.88 warnings (#15396) * Fix Clippy 1.88 warnings * Fmt --- .../desktop_native/core/src/ssh_agent/mod.rs | 9 ++++----- .../desktop_native/core/src/ssh_agent/unix.rs | 20 ++++--------------- .../desktop_native/macos_provider/src/lib.rs | 3 +-- apps/desktop/desktop_native/napi/src/lib.rs | 4 ++-- apps/desktop/desktop_native/objc/src/lib.rs | 8 ++------ apps/desktop/desktop_native/proxy/src/main.rs | 4 ++-- 6 files changed, 15 insertions(+), 33 deletions(-) diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index 5f794b49c73..63348904e46 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -51,7 +51,7 @@ impl ssh_agent::Agent for BitwardenDesktopAgent { let request_data = match request_parser::parse_request(data) { Ok(data) => data, Err(e) => { - println!("[SSH Agent] Error while parsing request: {}", e); + println!("[SSH Agent] Error while parsing request: {e}"); return false; } }; @@ -178,7 +178,7 @@ impl BitwardenDesktopAgent { ); } Err(e) => { - eprintln!("[SSH Agent Native Module] Error while parsing key: {}", e); + eprintln!("[SSH Agent Native Module] Error while parsing key: {e}"); } } } @@ -234,10 +234,9 @@ fn parse_key_safe(pem: &str) -> Result match key.public_key().to_bytes() { Ok(_) => Ok(key), Err(e) => Err(anyhow::Error::msg(format!( - "Failed to parse public key: {}", - e + "Failed to parse public key: {e}" ))), }, - Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {}", e))), + Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))), } } diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs index 6644da508f4..ed297a9002f 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs @@ -65,16 +65,10 @@ impl BitwardenDesktopAgent { } }; - println!( - "[SSH Agent Native Module] Starting SSH Agent server on {:?}", - ssh_path - ); + println!("[SSH Agent Native Module] Starting SSH Agent server on {ssh_path:?}"); let sockname = std::path::Path::new(&ssh_path); if let Err(e) = std::fs::remove_file(sockname) { - println!( - "[SSH Agent Native Module] Could not remove existing socket file: {}", - e - ); + println!("[SSH Agent Native Module] Could not remove existing socket file: {e}"); if e.kind() != std::io::ErrorKind::NotFound { return; } @@ -85,10 +79,7 @@ impl BitwardenDesktopAgent { // Only the current user should be able to access the socket if let Err(e) = fs::set_permissions(sockname, fs::Permissions::from_mode(0o600)) { - println!( - "[SSH Agent Native Module] Could not set socket permissions: {}", - e - ); + println!("[SSH Agent Native Module] Could not set socket permissions: {e}"); return; } @@ -112,10 +103,7 @@ impl BitwardenDesktopAgent { println!("[SSH Agent Native Module] SSH Agent server exited"); } Err(e) => { - eprintln!( - "[SSH Agent Native Module] Error while starting agent server: {}", - e - ); + eprintln!("[SSH Agent Native Module] Error while starting agent server: {e}"); } } }); diff --git a/apps/desktop/desktop_native/macos_provider/src/lib.rs b/apps/desktop/desktop_native/macos_provider/src/lib.rs index 8f2499ae68d..32d2514f1dd 100644 --- a/apps/desktop/desktop_native/macos_provider/src/lib.rs +++ b/apps/desktop/desktop_native/macos_provider/src/lib.rs @@ -214,8 +214,7 @@ impl MacOSProviderClient { .remove(&sequence_number) { cb.error(BitwardenError::Internal(format!( - "Error sending message: {}", - e + "Error sending message: {e}" ))); } } diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index e538dc8d432..fb80ec451a4 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -237,7 +237,7 @@ pub mod sshagent { .expect("should be able to send auth response to agent"); } Err(e) => { - println!("[SSH Agent Native Module] calling UI callback promise was rejected: {}", e); + println!("[SSH Agent Native Module] calling UI callback promise was rejected: {e}"); let _ = auth_response_tx_arc .lock() .await @@ -246,7 +246,7 @@ pub mod sshagent { } }, Err(e) => { - println!("[SSH Agent Native Module] calling UI callback could not create promise: {}", e); + println!("[SSH Agent Native Module] calling UI callback could not create promise: {e}"); let _ = auth_response_tx_arc .lock() .await diff --git a/apps/desktop/desktop_native/objc/src/lib.rs b/apps/desktop/desktop_native/objc/src/lib.rs index f5a7623cfc3..60e48760da8 100644 --- a/apps/desktop/desktop_native/objc/src/lib.rs +++ b/apps/desktop/desktop_native/objc/src/lib.rs @@ -80,8 +80,7 @@ mod objc { Ok(value) => value, Err(e) => { println!( - "Error: Failed to convert ObjCString to Rust string during commandReturn: {}", - e + "Error: Failed to convert ObjCString to Rust string during commandReturn: {e}" ); return false; @@ -91,10 +90,7 @@ mod objc { match context.send(value) { Ok(_) => 0, Err(e) => { - println!( - "Error: Failed to return ObjCString from ObjC code to Rust code: {}", - e - ); + println!("Error: Failed to return ObjCString from ObjC code to Rust code: {e}"); return false; } diff --git a/apps/desktop/desktop_native/proxy/src/main.rs b/apps/desktop/desktop_native/proxy/src/main.rs index ba29e00cf13..7b3337cce71 100644 --- a/apps/desktop/desktop_native/proxy/src/main.rs +++ b/apps/desktop/desktop_native/proxy/src/main.rs @@ -29,12 +29,12 @@ fn init_logging(log_path: &Path, console_level: LevelFilter, file_level: LevelFi loggers.push(simplelog::WriteLogger::new(file_level, config, file)); } Err(e) => { - eprintln!("Can't create file: {}", e); + eprintln!("Can't create file: {e}"); } } if let Err(e) = CombinedLogger::init(loggers) { - eprintln!("Failed to initialize logger: {}", e); + eprintln!("Failed to initialize logger: {e}"); } } From 5497063e7e79fd74c7dbef7ee3d30ba34b6bce52 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Tue, 1 Jul 2025 19:23:34 -0400 Subject: [PATCH 11/19] refactor(state): point storage imports to @bitwarden/storage-core (#15414) This change updates every import of StorageServiceProvider, AbstractStorageService, and ObservableStorageService throughout the common state code (including spec files) to pull from the new @bitwarden/storage-core package instead of their old relative paths. The cuts out one of the issues that needs to be resolved before state can hold its own as a library without importing common. --- .../implementations/default-active-user-state.spec.ts | 3 ++- .../implementations/default-global-state.provider.ts | 3 ++- .../state/implementations/default-global-state.ts | 6 ++---- .../implementations/default-single-user-state.provider.ts | 3 ++- .../state/implementations/default-single-user-state.ts | 6 ++---- .../state/implementations/specific-state.provider.spec.ts | 3 ++- .../src/platform/state/implementations/state-base.ts | 6 ++---- libs/common/src/platform/state/implementations/util.ts | 2 +- .../platform/state/state-event-registrar.service.spec.ts | 8 ++++++-- .../src/platform/state/state-event-runner.service.spec.ts | 8 ++++++-- .../src/platform/state/state-event-runner.service.ts | 3 ++- 11 files changed, 29 insertions(+), 22 deletions(-) diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts b/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts index b73415d6b79..1cb1453a509 100644 --- a/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts @@ -6,12 +6,13 @@ import { any, mock } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom, map, of, timeout } from "rxjs"; import { Jsonify } from "type-fest"; +import { StorageServiceProvider } from "@bitwarden/storage-core"; + import { awaitAsync, trackEmissions } from "../../../../spec"; import { FakeStorageService } from "../../../../spec/fake-storage.service"; import { Account } from "../../../auth/abstractions/account.service"; import { UserId } from "../../../types/guid"; import { LogService } from "../../abstractions/log.service"; -import { StorageServiceProvider } from "../../services/storage-service.provider"; import { StateDefinition } from "../state-definition"; import { StateEventRegistrarService } from "../state-event-registrar.service"; import { UserKeyDefinition } from "../user-key-definition"; diff --git a/libs/common/src/platform/state/implementations/default-global-state.provider.ts b/libs/common/src/platform/state/implementations/default-global-state.provider.ts index 18e9c21e75e..bd0cfc1dc9a 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-global-state.provider.ts @@ -1,7 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { StorageServiceProvider } from "@bitwarden/storage-core"; + import { LogService } from "../../abstractions/log.service"; -import { StorageServiceProvider } from "../../services/storage-service.provider"; import { GlobalState } from "../global-state"; import { GlobalStateProvider } from "../global-state.provider"; import { KeyDefinition } from "../key-definition"; diff --git a/libs/common/src/platform/state/implementations/default-global-state.ts b/libs/common/src/platform/state/implementations/default-global-state.ts index c88e9303c8e..a06eb23e010 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.ts +++ b/libs/common/src/platform/state/implementations/default-global-state.ts @@ -1,8 +1,6 @@ +import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core"; + import { LogService } from "../../abstractions/log.service"; -import { - AbstractStorageService, - ObservableStorageService, -} from "../../abstractions/storage.service"; import { GlobalState } from "../global-state"; import { KeyDefinition, globalKeyBuilder } from "../key-definition"; diff --git a/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts b/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts index 54e268e0b69..bef56ad2309 100644 --- a/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts @@ -1,8 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { StorageServiceProvider } from "@bitwarden/storage-core"; + import { UserId } from "../../../types/guid"; import { LogService } from "../../abstractions/log.service"; -import { StorageServiceProvider } from "../../services/storage-service.provider"; import { StateEventRegistrarService } from "../state-event-registrar.service"; import { UserKeyDefinition } from "../user-key-definition"; import { SingleUserState } from "../user-state"; diff --git a/libs/common/src/platform/state/implementations/default-single-user-state.ts b/libs/common/src/platform/state/implementations/default-single-user-state.ts index 1dafd3aecad..db776c3d11d 100644 --- a/libs/common/src/platform/state/implementations/default-single-user-state.ts +++ b/libs/common/src/platform/state/implementations/default-single-user-state.ts @@ -1,11 +1,9 @@ import { Observable, combineLatest, of } from "rxjs"; +import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core"; + import { UserId } from "../../../types/guid"; import { LogService } from "../../abstractions/log.service"; -import { - AbstractStorageService, - ObservableStorageService, -} from "../../abstractions/storage.service"; import { StateEventRegistrarService } from "../state-event-registrar.service"; import { UserKeyDefinition } from "../user-key-definition"; import { CombinedState, SingleUserState } from "../user-state"; diff --git a/libs/common/src/platform/state/implementations/specific-state.provider.spec.ts b/libs/common/src/platform/state/implementations/specific-state.provider.spec.ts index 1b5a36445c9..6674c2577d7 100644 --- a/libs/common/src/platform/state/implementations/specific-state.provider.spec.ts +++ b/libs/common/src/platform/state/implementations/specific-state.provider.spec.ts @@ -1,10 +1,11 @@ import { mock } from "jest-mock-extended"; +import { StorageServiceProvider } from "@bitwarden/storage-core"; + import { mockAccountServiceWith } from "../../../../spec/fake-account-service"; import { FakeStorageService } from "../../../../spec/fake-storage.service"; import { UserId } from "../../../types/guid"; import { LogService } from "../../abstractions/log.service"; -import { StorageServiceProvider } from "../../services/storage-service.provider"; import { KeyDefinition } from "../key-definition"; import { StateDefinition } from "../state-definition"; import { StateEventRegistrarService } from "../state-event-registrar.service"; diff --git a/libs/common/src/platform/state/implementations/state-base.ts b/libs/common/src/platform/state/implementations/state-base.ts index 578720a2281..03140e1fe1f 100644 --- a/libs/common/src/platform/state/implementations/state-base.ts +++ b/libs/common/src/platform/state/implementations/state-base.ts @@ -15,12 +15,10 @@ import { } from "rxjs"; import { Jsonify } from "type-fest"; +import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core"; + import { StorageKey } from "../../../types/state"; import { LogService } from "../../abstractions/log.service"; -import { - AbstractStorageService, - ObservableStorageService, -} from "../../abstractions/storage.service"; import { DebugOptions } from "../key-definition"; import { populateOptionsWithDefault, StateUpdateOptions } from "../state-update-options"; diff --git a/libs/common/src/platform/state/implementations/util.ts b/libs/common/src/platform/state/implementations/util.ts index 0a9d76f6da5..14b11f6b553 100644 --- a/libs/common/src/platform/state/implementations/util.ts +++ b/libs/common/src/platform/state/implementations/util.ts @@ -1,6 +1,6 @@ import { Jsonify } from "type-fest"; -import { AbstractStorageService } from "../../abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/storage-core"; export async function getStoredValue( key: string, diff --git a/libs/common/src/platform/state/state-event-registrar.service.spec.ts b/libs/common/src/platform/state/state-event-registrar.service.spec.ts index 2fae985033b..b022e2ce413 100644 --- a/libs/common/src/platform/state/state-event-registrar.service.spec.ts +++ b/libs/common/src/platform/state/state-event-registrar.service.spec.ts @@ -1,8 +1,12 @@ import { mock } from "jest-mock-extended"; +import { + AbstractStorageService, + ObservableStorageService, + StorageServiceProvider, +} from "@bitwarden/storage-core"; + import { FakeGlobalStateProvider } from "../../../spec"; -import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service"; -import { StorageServiceProvider } from "../services/storage-service.provider"; import { StateDefinition } from "./state-definition"; import { STATE_LOCK_EVENT, StateEventRegistrarService } from "./state-event-registrar.service"; diff --git a/libs/common/src/platform/state/state-event-runner.service.spec.ts b/libs/common/src/platform/state/state-event-runner.service.spec.ts index 1c98099a518..4aef3d8516c 100644 --- a/libs/common/src/platform/state/state-event-runner.service.spec.ts +++ b/libs/common/src/platform/state/state-event-runner.service.spec.ts @@ -1,9 +1,13 @@ import { mock } from "jest-mock-extended"; +import { + AbstractStorageService, + ObservableStorageService, + StorageServiceProvider, +} from "@bitwarden/storage-core"; + import { FakeGlobalStateProvider } from "../../../spec"; import { UserId } from "../../types/guid"; -import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service"; -import { StorageServiceProvider } from "../services/storage-service.provider"; import { STATE_LOCK_EVENT } from "./state-event-registrar.service"; import { StateEventRunnerService } from "./state-event-runner.service"; diff --git a/libs/common/src/platform/state/state-event-runner.service.ts b/libs/common/src/platform/state/state-event-runner.service.ts index f24c50f86d6..9e6f8f214e1 100644 --- a/libs/common/src/platform/state/state-event-runner.service.ts +++ b/libs/common/src/platform/state/state-event-runner.service.ts @@ -2,8 +2,9 @@ // @ts-strict-ignore import { firstValueFrom } from "rxjs"; +import { StorageServiceProvider } from "@bitwarden/storage-core"; + import { UserId } from "../../types/guid"; -import { StorageServiceProvider } from "../services/storage-service.provider"; import { GlobalState } from "./global-state"; import { GlobalStateProvider } from "./global-state.provider"; From 1837974e0a86be48b519c9974476f4fb9a995bff Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Wed, 2 Jul 2025 09:41:35 -0400 Subject: [PATCH 12/19] show failed to decrypt dialog when viewing a cipher on desktop (#15405) --- apps/desktop/src/vault/app/vault/vault-v2.component.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 1248f32d1ac..2d741944071 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -388,6 +388,13 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { } async viewCipher(cipher: CipherView) { + if (cipher.decryptionFailure) { + DecryptionFailureDialogComponent.open(this.dialogService, { + cipherIds: [cipher.id as CipherId], + }); + return; + } + if (await this.shouldReprompt(cipher, "view")) { return; } From cc65f5efc6a417acea6e6349e3e3d97783f20139 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Wed, 2 Jul 2025 07:23:45 -0700 Subject: [PATCH 13/19] feat(set-initial-password): [Auth/PM-18784] SetInitialPasswordComponent Handle TDE Offboarding (#14861) This PR makes it so that `SetInitialPasswordComponent` handles the TDE offboarding flow where an org user now needs to set an initial master password. Feature flag: `PM16117_SetInitialPasswordRefactor` --- ...initial-password.service.implementation.ts | 42 +++++ ...fault-set-initial-password.service.spec.ts | 132 ++++++++++++++-- .../set-initial-password.component.html | 7 +- .../set-initial-password.component.ts | 148 ++++++++++++------ ...et-initial-password.service.abstraction.ts | 25 +++ .../src/auth/utils/assert-non-nullish.util.ts | 45 ++++++ .../src/auth/utils/assert-truthy.util.ts | 46 ++++++ libs/common/src/auth/utils/index.ts | 2 + 8 files changed, 391 insertions(+), 56 deletions(-) create mode 100644 libs/common/src/auth/utils/assert-non-nullish.util.ts create mode 100644 libs/common/src/auth/utils/assert-truthy.util.ts create mode 100644 libs/common/src/auth/utils/index.ts diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts index 1c5edb00c8c..dd81f560939 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts @@ -14,6 +14,7 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; +import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; @@ -28,6 +29,7 @@ import { SetInitialPasswordService, SetInitialPasswordCredentials, SetInitialPasswordUserType, + SetInitialPasswordTdeOffboardingCredentials, } from "./set-initial-password.service.abstraction"; export class DefaultSetInitialPasswordService implements SetInitialPasswordService { @@ -245,4 +247,44 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi enrollmentRequest, ); } + + async setInitialPasswordTdeOffboarding( + credentials: SetInitialPasswordTdeOffboardingCredentials, + userId: UserId, + ) { + const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials; + for (const [key, value] of Object.entries(credentials)) { + if (value == null) { + throw new Error(`${key} not found. Could not set password.`); + } + } + + if (userId == null) { + throw new Error("userId not found. Could not set password."); + } + + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + if (userKey == null) { + throw new Error("userKey not found. Could not set password."); + } + + const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey( + newMasterKey, + userKey, + ); + + if (!newMasterKeyEncryptedUserKey[1].encryptedString) { + throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password."); + } + + const request = new UpdateTdeOffboardingPasswordRequest(); + request.key = newMasterKeyEncryptedUserKey[1].encryptedString; + request.newMasterPasswordHash = newServerMasterKeyHash; + request.masterPasswordHint = newPasswordHint; + + await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request); + + // Clear force set password reason to allow navigation back to vault. + await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId); + } } diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts index ca4d9adbd67..979dc5ee82f 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts @@ -19,6 +19,7 @@ import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; +import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; @@ -35,6 +36,7 @@ import { DefaultSetInitialPasswordService } from "./default-set-initial-password import { SetInitialPasswordCredentials, SetInitialPasswordService, + SetInitialPasswordTdeOffboardingCredentials, SetInitialPasswordUserType, } from "./set-initial-password.service.abstraction"; @@ -52,6 +54,11 @@ describe("DefaultSetInitialPasswordService", () => { let organizationUserApiService: MockProxy; let userDecryptionOptionsService: MockProxy; + let userId: UserId; + let userKey: UserKey; + let userKeyEncString: EncString; + let masterKeyEncryptedUserKey: [UserKey, EncString]; + beforeEach(() => { apiService = mock(); encryptService = mock(); @@ -64,6 +71,11 @@ describe("DefaultSetInitialPasswordService", () => { organizationUserApiService = mock(); userDecryptionOptionsService = mock(); + userId = "userId" as UserId; + userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + userKeyEncString = new EncString("masterKeyEncryptedUserKey"); + masterKeyEncryptedUserKey = [userKey, userKeyEncString]; + sut = new DefaultSetInitialPasswordService( apiService, encryptService, @@ -86,13 +98,8 @@ describe("DefaultSetInitialPasswordService", () => { // Mock function parameters let credentials: SetInitialPasswordCredentials; let userType: SetInitialPasswordUserType; - let userId: UserId; // Mock other function data - let userKey: UserKey; - let userKeyEncString: EncString; - let masterKeyEncryptedUserKey: [UserKey, EncString]; - let existingUserPublicKey: UserPublicKey; let existingUserPrivateKey: UserPrivateKey; let userKeyEncryptedPrivateKey: EncString; @@ -121,14 +128,9 @@ describe("DefaultSetInitialPasswordService", () => { orgId: "orgId", resetPasswordAutoEnroll: false, }; - userId = "userId" as UserId; userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; // Mock other function data - userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; - userKeyEncString = new EncString("masterKeyEncryptedUserKey"); - masterKeyEncryptedUserKey = [userKey, userKeyEncString]; - existingUserPublicKey = Utils.fromB64ToArray("existingUserPublicKey") as UserPublicKey; existingUserPrivateKey = Utils.fromB64ToArray("existingUserPrivateKey") as UserPrivateKey; userKeyEncryptedPrivateKey = new EncString("userKeyEncryptedPrivateKey"); @@ -630,4 +632,114 @@ describe("DefaultSetInitialPasswordService", () => { }); }); }); + + describe("setInitialPasswordTdeOffboarding(...)", () => { + // Mock function parameters + let credentials: SetInitialPasswordTdeOffboardingCredentials; + + beforeEach(() => { + // Mock function parameters + credentials = { + newMasterKey: new SymmetricCryptoKey(new Uint8Array(32).buffer as CsprngArray) as MasterKey, + newServerMasterKeyHash: "newServerMasterKeyHash", + newPasswordHint: "newPasswordHint", + }; + }); + + function setupTdeOffboardingMocks() { + keyService.userKey$.mockReturnValue(of(userKey)); + keyService.encryptUserKeyWithMasterKey.mockResolvedValue(masterKeyEncryptedUserKey); + } + + it("should successfully set an initial password for the TDE offboarding user", async () => { + // Arrange + setupTdeOffboardingMocks(); + + const request = new UpdateTdeOffboardingPasswordRequest(); + request.key = masterKeyEncryptedUserKey[1].encryptedString; + request.newMasterPasswordHash = credentials.newServerMasterKeyHash; + request.masterPasswordHint = credentials.newPasswordHint; + + // Act + await sut.setInitialPasswordTdeOffboarding(credentials, userId); + + // Assert + expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1); + expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledWith( + request, + ); + }); + + describe("given the initial password has been successfully set", () => { + it("should clear the ForceSetPasswordReason by setting it to None", async () => { + // Arrange + setupTdeOffboardingMocks(); + + // Act + await sut.setInitialPasswordTdeOffboarding(credentials, userId); + + // Assert + expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1); + expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.None, + userId, + ); + }); + }); + + describe("general error handling", () => { + ["newMasterKey", "newServerMasterKeyHash", "newPasswordHint"].forEach((key) => { + it(`should throw if ${key} is not provided on the SetInitialPasswordTdeOffboardingCredentials object`, async () => { + // Arrange + const invalidCredentials: SetInitialPasswordTdeOffboardingCredentials = { + ...credentials, + [key]: null, + }; + + // Act + const promise = sut.setInitialPasswordTdeOffboarding(invalidCredentials, userId); + + // Assert + await expect(promise).rejects.toThrow(`${key} not found. Could not set password.`); + }); + }); + + it(`should throw if the userId was not passed in`, async () => { + // Arrange + userId = null; + + // Act + const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId); + + // Assert + await expect(promise).rejects.toThrow("userId not found. Could not set password."); + }); + + it(`should throw if the userKey was not found`, async () => { + // Arrange + keyService.userKey$.mockReturnValue(of(null)); + + // Act + const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId); + + // Assert + await expect(promise).rejects.toThrow("userKey not found. Could not set password."); + }); + + it(`should throw if a newMasterKeyEncryptedUserKey was not returned`, async () => { + // Arrange + masterKeyEncryptedUserKey[1].encryptedString = "" as EncryptedString; + + setupTdeOffboardingMocks(); + + // Act + const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId); + + // Assert + await expect(promise).rejects.toThrow( + "newMasterKeyEncryptedUserKey not found. Could not set password.", + ); + }); + }); + }); }); diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.html b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.html index c83cbbe3521..4956f293d1e 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.html +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.html @@ -21,7 +21,12 @@ [userId]="userId" [loading]="submitting" [masterPasswordPolicyOptions]="masterPasswordPolicyOptions" - [primaryButtonText]="{ key: 'createAccount' }" + [primaryButtonText]="{ + key: + userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER + ? 'setPassword' + : 'createAccount', + }" [secondaryButtonText]="{ key: 'logOut' }" (onPasswordFormSubmit)="handlePasswordFormSubmit($event)" (onSecondaryButtonClick)="logout()" diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts index fbab9eaa2c3..2de9aaf7b75 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts @@ -10,14 +10,20 @@ import { InputPasswordFlow, PasswordInputResult, } from "@bitwarden/auth/angular"; +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { LogoutService } from "@bitwarden/auth/common"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { assertTruthy, assertNonNullish } from "@bitwarden/common/auth/utils"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { SyncService } from "@bitwarden/common/platform/sync"; @@ -33,6 +39,7 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { SetInitialPasswordCredentials, SetInitialPasswordService, + SetInitialPasswordTdeOffboardingCredentials, SetInitialPasswordUserType, } from "./set-initial-password.service.abstraction"; @@ -54,6 +61,7 @@ export class SetInitialPasswordComponent implements OnInit { protected submitting = false; protected userId?: UserId; protected userType?: SetInitialPasswordUserType; + protected SetInitialPasswordUserType = SetInitialPasswordUserType; constructor( private accountService: AccountService, @@ -61,10 +69,13 @@ export class SetInitialPasswordComponent implements OnInit { private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, private dialogService: DialogService, private i18nService: I18nService, + private logoutService: LogoutService, + private logService: LogService, private masterPasswordService: InternalMasterPasswordServiceAbstraction, private messagingService: MessagingService, private organizationApiService: OrganizationApiServiceAbstraction, private policyApiService: PolicyApiServiceAbstraction, + private policyService: PolicyService, private router: Router, private setInitialPasswordService: SetInitialPasswordService, private ssoLoginService: SsoLoginServiceAbstraction, @@ -80,13 +91,13 @@ export class SetInitialPasswordComponent implements OnInit { this.userId = activeAccount?.id; this.email = activeAccount?.email; - await this.determineUserType(); - await this.handleQueryParams(); + await this.establishUserType(); + await this.getOrgInfo(); this.initializing = false; } - private async determineUserType() { + private async establishUserType() { if (!this.userId) { throw new Error("userId not found. Could not determine user type."); } @@ -95,6 +106,14 @@ export class SetInitialPasswordComponent implements OnInit { this.masterPasswordService.forceSetPasswordReason$(this.userId), ); + if (this.forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser) { + this.userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { key: "joinOrganization" }, + pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword" }, + }); + } + if ( this.forceSetPasswordReason === ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission @@ -104,20 +123,35 @@ export class SetInitialPasswordComponent implements OnInit { pageTitle: { key: "setMasterPassword" }, pageSubtitle: { key: "orgPermissionsUpdatedMustSetPassword" }, }); - } else { - this.userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; + } + + if (this.forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding) { + this.userType = SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER; this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ - pageTitle: { key: "joinOrganization" }, - pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword" }, + pageTitle: { key: "setMasterPassword" }, + pageSubtitle: { key: "tdeDisabledMasterPasswordRequired" }, }); } + + // If we somehow end up here without a reason, navigate to root + if (this.forceSetPasswordReason === ForceSetPasswordReason.None) { + await this.router.navigate(["/"]); + } } - private async handleQueryParams() { + private async getOrgInfo() { if (!this.userId) { throw new Error("userId not found. Could not handle query params."); } + if (this.userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER) { + this.masterPasswordPolicyOptions = + (await firstValueFrom(this.policyService.masterPasswordPolicyOptions$(this.userId))) ?? + null; + + return; + } + const qParams = await firstValueFrom(this.activatedRoute.queryParams); this.orgSsoIdentifier = @@ -146,38 +180,34 @@ export class SetInitialPasswordComponent implements OnInit { protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { this.submitting = true; - if (!passwordInputResult.newMasterKey) { - throw new Error("newMasterKey not found. Could not set initial password."); - } - if (!passwordInputResult.newServerMasterKeyHash) { - throw new Error("newServerMasterKeyHash not found. Could not set initial password."); - } - if (!passwordInputResult.newLocalMasterKeyHash) { - throw new Error("newLocalMasterKeyHash not found. Could not set initial password."); - } - // newPasswordHint can have an empty string as a valid value, so we specifically check for null or undefined - if (passwordInputResult.newPasswordHint == null) { - throw new Error("newPasswordHint not found. Could not set initial password."); - } - if (!passwordInputResult.kdfConfig) { - throw new Error("kdfConfig not found. Could not set initial password."); - } - if (!this.userId) { - throw new Error("userId not found. Could not set initial password."); - } - if (!this.userType) { - throw new Error("userType not found. Could not set initial password."); - } - if (!this.orgSsoIdentifier) { - throw new Error("orgSsoIdentifier not found. Could not set initial password."); - } - if (!this.orgId) { - throw new Error("orgId not found. Could not set initial password."); - } - // resetPasswordAutoEnroll can have `false` as a valid value, so we specifically check for null or undefined - if (this.resetPasswordAutoEnroll == null) { - throw new Error("resetPasswordAutoEnroll not found. Could not set initial password."); + switch (this.userType) { + case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: + case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP: + await this.setInitialPassword(passwordInputResult); + break; + case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER: + await this.setInitialPasswordTdeOffboarding(passwordInputResult); + break; + default: + this.logService.error( + `Unexpected user type: ${this.userType}. Could not set initial password.`, + ); + this.validationService.showError("Unexpected user type. Could not set initial password."); } + } + + private async setInitialPassword(passwordInputResult: PasswordInputResult) { + const ctx = "Could not set initial password."; + assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx); + assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx); + assertTruthy(passwordInputResult.newLocalMasterKeyHash, "newLocalMasterKeyHash", ctx); + assertTruthy(passwordInputResult.kdfConfig, "kdfConfig", ctx); + assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx); + assertTruthy(this.orgId, "orgId", ctx); + assertTruthy(this.userType, "userType", ctx); + assertTruthy(this.userId, "userId", ctx); + assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish + assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish try { const credentials: SetInitialPasswordCredentials = { @@ -202,11 +232,44 @@ export class SetInitialPasswordComponent implements OnInit { this.submitting = false; await this.router.navigate(["vault"]); } catch (e) { + this.logService.error("Error setting initial password", e); this.validationService.showError(e); this.submitting = false; } } + private async setInitialPasswordTdeOffboarding(passwordInputResult: PasswordInputResult) { + const ctx = "Could not set initial password."; + assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx); + assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx); + assertTruthy(this.userId, "userId", ctx); + assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish + + try { + const credentials: SetInitialPasswordTdeOffboardingCredentials = { + newMasterKey: passwordInputResult.newMasterKey, + newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash, + newPasswordHint: passwordInputResult.newPasswordHint, + }; + + await this.setInitialPasswordService.setInitialPasswordTdeOffboarding( + credentials, + this.userId, + ); + + this.showSuccessToastByUserType(); + + await this.logoutService.logout(this.userId); + // navigate to root so redirect guard can properly route next active user or null user to correct page + await this.router.navigate(["/"]); + } catch (e) { + this.logService.error("Error setting initial password during TDE offboarding", e); + this.validationService.showError(e); + } finally { + this.submitting = false; + } + } + private showSuccessToastByUserType() { if (this.userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) { this.toastService.showToast({ @@ -220,12 +283,7 @@ export class SetInitialPasswordComponent implements OnInit { title: "", message: this.i18nService.t("inviteAccepted"), }); - } - - if ( - this.userType === - SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP - ) { + } else { this.toastService.showToast({ variant: "success", title: "", diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts index e594053a906..c167c1675c1 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts @@ -19,6 +19,12 @@ export const _SetInitialPasswordUserType = { */ TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP: "tde_org_user_reset_password_permission_requires_mp", + + /** + * A user in an org that offboarded from trusted device encryption and is now a + * master-password-encryption org + */ + OFFBOARDED_TDE_ORG_USER: "offboarded_tde_org_user", } as const; type _SetInitialPasswordUserType = typeof _SetInitialPasswordUserType; @@ -40,6 +46,12 @@ export interface SetInitialPasswordCredentials { resetPasswordAutoEnroll: boolean; } +export interface SetInitialPasswordTdeOffboardingCredentials { + newMasterKey: MasterKey; + newServerMasterKeyHash: string; + newPasswordHint: string; +} + /** * Handles setting an initial password for an existing authed user. * @@ -61,4 +73,17 @@ export abstract class SetInitialPasswordService { userType: SetInitialPasswordUserType, userId: UserId, ) => Promise; + + /** + * Sets an initial password for a user who logs in after their org offboarded from + * trusted device encryption and is now a master-password-encryption org: + * - {@link SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER} + * + * @param passwordInputResult credentials object received from the `InputPasswordComponent` + * @param userId the account `userId` + */ + abstract setInitialPasswordTdeOffboarding: ( + credentials: SetInitialPasswordTdeOffboardingCredentials, + userId: UserId, + ) => Promise; } diff --git a/libs/common/src/auth/utils/assert-non-nullish.util.ts b/libs/common/src/auth/utils/assert-non-nullish.util.ts new file mode 100644 index 00000000000..91fb8ef44b8 --- /dev/null +++ b/libs/common/src/auth/utils/assert-non-nullish.util.ts @@ -0,0 +1,45 @@ +/** + * Asserts that a value is non-nullish (not `null` or `undefined`); throws if value is nullish. + * + * @param val the value to check + * @param name the name of the value to include in the error message + * @param ctx context to optionally append to the error message + * @throws if the value is null or undefined + * + * @example + * + * ``` + * // `newPasswordHint` can have an empty string as a valid value, so we check non-nullish + * this.assertNonNullish( + * passwordInputResult.newPasswordHint, + * "newPasswordHint", + * "Could not set initial password." + * ); + * // Output error message: "newPasswordHint is null or undefined. Could not set initial password." + * ``` + * + * @remarks + * + * If you use this method repeatedly to check several values, it may help to assign any + * additional context (`ctx`) to a variable and pass it in to each call. This prevents the + * call from reformatting vertically via prettier in your text editor, taking up multiple lines. + * + * For example: + * ``` + * const ctx = "Could not set initial password."; + * + * this.assertNonNullish(valueOne, "valueOne", ctx); + * this.assertNonNullish(valueTwo, "valueTwo", ctx); + * this.assertNonNullish(valueThree, "valueThree", ctx); + * ``` + */ +export function assertNonNullish( + val: T, + name: string, + ctx?: string, +): asserts val is NonNullable { + if (val == null) { + // If context is provided, append it to the error message with a space before it. + throw new Error(`${name} is null or undefined.${ctx ? ` ${ctx}` : ""}`); + } +} diff --git a/libs/common/src/auth/utils/assert-truthy.util.ts b/libs/common/src/auth/utils/assert-truthy.util.ts new file mode 100644 index 00000000000..8e003186929 --- /dev/null +++ b/libs/common/src/auth/utils/assert-truthy.util.ts @@ -0,0 +1,46 @@ +/** + * Asserts that a value is truthy; throws if value is falsy. + * + * @param val the value to check + * @param name the name of the value to include in the error message + * @param ctx context to optionally append to the error message + * @throws if the value is falsy (`false`, `""`, `0`, `null`, `undefined`, `void`, or `NaN`) + * + * @example + * + * ``` + * this.assertTruthy( + * this.organizationId, + * "organizationId", + * "Could not set initial password." + * ); + * // Output error message: "organizationId is falsy. Could not set initial password." + * ``` + * + * @remarks + * + * If you use this method repeatedly to check several values, it may help to assign any + * additional context (`ctx`) to a variable and pass it in to each call. This prevents the + * call from reformatting vertically via prettier in your text editor, taking up multiple lines. + * + * For example: + * ``` + * const ctx = "Could not set initial password."; + * + * this.assertTruthy(valueOne, "valueOne", ctx); + * this.assertTruthy(valueTwo, "valueTwo", ctx); + * this.assertTruthy(valueThree, "valueThree", ctx); + */ +export function assertTruthy( + val: T, + name: string, + ctx?: string, +): asserts val is Exclude { + // Because `NaN` is a value (not a type) of type 'number', that means we cannot add + // it to the list of falsy values in the type assertion. Instead, we check for it + // separately at runtime. + if (!val || (typeof val === "number" && Number.isNaN(val))) { + // If context is provided, append it to the error message with a space before it. + throw new Error(`${name} is falsy.${ctx ? ` ${ctx}` : ""}`); + } +} diff --git a/libs/common/src/auth/utils/index.ts b/libs/common/src/auth/utils/index.ts new file mode 100644 index 00000000000..96bab53d3f9 --- /dev/null +++ b/libs/common/src/auth/utils/index.ts @@ -0,0 +1,2 @@ +export { assertTruthy } from "./assert-truthy.util"; +export { assertNonNullish } from "./assert-non-nullish.util"; From 0d204fb9f77335f30be185e644476b843e1cfcad Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Wed, 2 Jul 2025 10:30:41 -0400 Subject: [PATCH 14/19] feat(manifest): [Auth/PM-14942] add notifications to requested permissions (#15316) --- apps/browser/src/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index c46674083b2..aa80222c672 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -56,7 +56,8 @@ "unlimitedStorage", "webNavigation", "webRequest", - "webRequestBlocking" + "webRequestBlocking", + "notifications" ], "__safari__permissions": [ "", From 87a42cc5071109db20794ae7dcfe850059974e79 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Wed, 2 Jul 2025 15:40:45 +0000 Subject: [PATCH 15/19] Bumped Desktop client to 2025.7.0 --- apps/desktop/package.json | 2 +- apps/desktop/src/package-lock.json | 4 ++-- apps/desktop/src/package.json | 2 +- package-lock.json | 4 +++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4a59d5bbcf0..2ab88fed621 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.6.1", + "version": "2025.7.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 128cf94a09d..a4e00046476 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.6.1", + "version": "2025.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.6.1", + "version": "2025.7.0", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 9c6d5712b6d..0128692f3b4 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.6.1", + "version": "2025.7.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/package-lock.json b/package-lock.json index d7c23e0997b..900dc0d0b00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -288,7 +288,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.6.1", + "version": "2025.7.0", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -354,6 +354,7 @@ "license": "GPL-3.0" }, "libs/logging": { + "name": "@bitwarden/logging", "version": "0.0.1", "license": "GPL-3.0" }, @@ -428,6 +429,7 @@ "license": "GPL-3.0" }, "libs/user-core": { + "name": "@bitwarden/user-core", "version": "0.0.0", "license": "GPL-3.0" }, From 369c1edaf782fc2799362c79e16cef48aae7b178 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 2 Jul 2025 08:54:42 -0700 Subject: [PATCH 16/19] [PM-22376] - [Vault] [Clients] Update cipher form component to default to My Items collections (#15356) * fix tests * remove unused code * fix storybook * fix storybook * cleanup * move observable to function. update tests * fix type error * move call to getDefaultCollectionId * fix test --- .../src/cipher-form/cipher-form.stories.ts | 8 +++ .../item-details-section.component.spec.ts | 53 ++++++++++++-- .../item-details-section.component.ts | 71 ++++++++++++++++--- 3 files changed, 115 insertions(+), 17 deletions(-) diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index f46eb457e30..25494350dc3 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -19,6 +19,7 @@ import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import { NudgeStatus, NudgesService } from "@bitwarden/angular/vault"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; @@ -243,6 +244,7 @@ export default { provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false), + getFeatureFlag$: () => new BehaviorSubject(false), }, }, { @@ -253,6 +255,12 @@ export default { }, }, }, + { + provide: PolicyService, + useValue: { + policiesByType$: new BehaviorSubject([]), + }, + }, ], }), componentWrapperDecorator( diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index 12fba0c7409..99c377a0873 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -3,18 +3,24 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testin import { ReactiveFormsModule } from "@angular/forms"; import { By } from "@angular/platform-browser"; import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { SelectComponent } from "@bitwarden/components"; -import { CipherFormConfig } from "../../abstractions/cipher-form-config.service"; +import { + CipherFormConfig, + OptionalInitialValues, +} from "../../abstractions/cipher-form-config.service"; import { CipherFormContainer } from "../../cipher-form-container"; import { ItemDetailsSectionComponent } from "./item-details-section.component"; @@ -48,6 +54,8 @@ describe("ItemDetailsSectionComponent", () => { let fixture: ComponentFixture; let cipherFormProvider: MockProxy; let i18nService: MockProxy; + let mockConfigService: MockProxy; + let mockPolicyService: MockProxy; const activeAccount$ = new BehaviorSubject<{ email: string }>({ email: "test@example.com" }); const getInitialCipherView = jest.fn(() => null); @@ -66,12 +74,19 @@ describe("ItemDetailsSectionComponent", () => { compare: (a: string, b: string) => a.localeCompare(b), } as Intl.Collator; + mockConfigService = mock(); + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockPolicyService = mock(); + mockPolicyService.policiesByType$.mockReturnValue(of([])); + await TestBed.configureTestingModule({ imports: [ItemDetailsSectionComponent, CommonModule, ReactiveFormsModule], providers: [ { provide: CipherFormContainer, useValue: cipherFormProvider }, { provide: I18nService, useValue: i18nService }, { provide: AccountService, useValue: { activeAccount$ } }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: PolicyService, useValue: mockPolicyService }, ], }).compileComponents(); @@ -369,7 +384,7 @@ describe("ItemDetailsSectionComponent", () => { expect(collectionSelect).toBeNull(); }); - it("should enable/show collection control when an organization is selected", async () => { + it("should enable/show collection control when an organization is selected", fakeAsync(() => { component.config.organizationDataOwnershipDisabled = true; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ @@ -378,12 +393,12 @@ describe("ItemDetailsSectionComponent", () => { ]; fixture.detectChanges(); - await fixture.whenStable(); + tick(); component.itemDetailsForm.controls.organizationId.setValue("org1"); + tick(); fixture.detectChanges(); - await fixture.whenStable(); const collectionSelect = fixture.nativeElement.querySelector( "bit-multi-select[formcontrolname='collectionIds']", @@ -391,7 +406,7 @@ describe("ItemDetailsSectionComponent", () => { expect(component.itemDetailsForm.controls.collectionIds.enabled).toBe(true); expect(collectionSelect).not.toBeNull(); - }); + })); it("should set collectionIds to originalCipher collections on first load", async () => { component.config.mode = "clone"; @@ -488,6 +503,9 @@ describe("ItemDetailsSectionComponent", () => { component.itemDetailsForm.controls.organizationId.setValue("org1"); + fixture.detectChanges(); + await fixture.whenStable(); + expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]); }); }); @@ -548,4 +566,27 @@ describe("ItemDetailsSectionComponent", () => { expect(label).toBe("org1"); }); }); + + describe("getDefaultCollectionId", () => { + it("returns matching default when flag & policy match", async () => { + const def = createMockCollection("def1", "Def", "orgA"); + component.config.collections = [def] as CollectionView[]; + component.config.initialValues = { collectionIds: [] } as OptionalInitialValues; + mockConfigService.getFeatureFlag.mockResolvedValue(true); + mockPolicyService.policiesByType$.mockReturnValue(of([{ organizationId: "orgA" } as Policy])); + + const id = await (component as any).getDefaultCollectionId("orgA"); + expect(id).toEqual("def1"); + }); + + it("returns undefined when no default found", async () => { + component.config.collections = [createMockCollection("c1", "C1", "orgB")] as CollectionView[]; + component.config.initialValues = { collectionIds: [] } as OptionalInitialValues; + mockConfigService.getFeatureFlag.mockResolvedValue(true); + mockPolicyService.policiesByType$.mockReturnValue(of([{ organizationId: "orgA" } as Policy])); + + const result = await (component as any).getDefaultCollectionId("orgA"); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index 1d30edf27e0..1064980050f 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -4,15 +4,19 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; -import { concatMap, map } from "rxjs"; +import { concatMap, firstValueFrom, map } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { OrganizationUserType, PolicyType } from "@bitwarden/common/admin-console/enums"; 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 { Utils } from "@bitwarden/common/platform/misc/utils"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; @@ -124,6 +128,8 @@ export class ItemDetailsSectionComponent implements OnInit { private i18nService: I18nService, private destroyRef: DestroyRef, private accountService: AccountService, + private configService: ConfigService, + private policyService: PolicyService, ) { this.cipherFormContainer.registerChildForm("itemDetails", this.itemDetailsForm); this.itemDetailsForm.valueChanges @@ -200,30 +206,61 @@ export class ItemDetailsSectionComponent implements OnInit { if (prefillCipher) { await this.initFromExistingCipher(prefillCipher); } else { + const orgId = this.initialValues?.organizationId; this.itemDetailsForm.setValue({ name: this.initialValues?.name || "", - organizationId: this.initialValues?.organizationId || this.defaultOwner, + organizationId: orgId || this.defaultOwner, folderId: this.initialValues?.folderId || null, collectionIds: [], favorite: false, }); - await this.updateCollectionOptions(this.initialValues?.collectionIds || []); + await this.updateCollectionOptions(this.initialValues?.collectionIds); } - if (!this.allowOwnershipChange) { this.itemDetailsForm.controls.organizationId.disable(); } - this.itemDetailsForm.controls.organizationId.valueChanges .pipe( takeUntilDestroyed(this.destroyRef), - concatMap(async () => { - await this.updateCollectionOptions(); - }), + concatMap(async () => await this.updateCollectionOptions()), ) .subscribe(); } + /** + * Gets the default collection IDs for the selected organization. + * Returns null if any of the following apply: + * - the feature flag is disabled + * - no org is currently selected + * - the selected org doesn't have the "no private data policy" enabled + */ + private async getDefaultCollectionId(orgId?: OrganizationId) { + if (!orgId) { + return; + } + const isFeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.CreateDefaultLocation, + ); + if (!isFeatureEnabled) { + return; + } + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const selectedOrgHasPolicyEnabled = ( + await firstValueFrom( + this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId), + ) + ).find((p) => p.organizationId); + if (!selectedOrgHasPolicyEnabled) { + return; + } + const defaultUserCollection = this.collections.find( + (c) => c.organizationId === orgId && c.type === CollectionTypes.DefaultUserCollection, + ); + // If the user was added after the policy was enabled as they will not have any private data + // and will not have a default collection. + return defaultUserCollection?.id; + } + private async initFromExistingCipher(prefillCipher: CipherView) { const { name, folderId, collectionIds } = prefillCipher; @@ -332,6 +369,11 @@ export class ItemDetailsSectionComponent implements OnInit { // Non-admins can only select assigned collections that are not read only. (Non-AC) return c.assigned && !c.readOnly; }) + .sort((a, b) => { + const aIsDefaultCollection = a.type === CollectionTypes.DefaultUserCollection ? -1 : 0; + const bIsDefaultCollection = b.type === CollectionTypes.DefaultUserCollection ? -1 : 0; + return aIsDefaultCollection - bIsDefaultCollection; + }) .map((c) => ({ id: c.id, name: c.name, @@ -349,10 +391,17 @@ export class ItemDetailsSectionComponent implements OnInit { return; } - if (startingSelection.length > 0) { + if (startingSelection.filter(Boolean).length > 0) { collectionsControl.setValue( this.collectionOptions.filter((c) => startingSelection.includes(c.id as CollectionId)), ); + } else { + const defaultCollectionId = await this.getDefaultCollectionId(orgId); + if (defaultCollectionId) { + collectionsControl.setValue( + this.collectionOptions.filter((c) => c.id === defaultCollectionId), + ); + } } } } From 023b057f3e6be2f4203491aa88f247ab2b94a532 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Wed, 2 Jul 2025 14:08:05 -0400 Subject: [PATCH 17/19] [CL-124] Add validator stories (#15400) * adding validation stories * add one story for all validations * fix form field story import * move validation docs * fix maxValue default value * add play function to submit form --- .../src/form-field/form-field.stories.ts | 2 +- libs/components/src/form/form.stories.ts | 122 +++++++++++++++--- libs/components/src/form/forms.mdx | 18 ++- 3 files changed, 116 insertions(+), 26 deletions(-) diff --git a/libs/components/src/form-field/form-field.stories.ts b/libs/components/src/form-field/form-field.stories.ts index 738ac96bf76..0c1fa8e6f6c 100644 --- a/libs/components/src/form-field/form-field.stories.ts +++ b/libs/components/src/form-field/form-field.stories.ts @@ -72,6 +72,7 @@ export default { decorators: [ moduleMetadata({ imports: [ + A11yTitleDirective, FormsModule, ReactiveFormsModule, FormFieldModule, @@ -88,7 +89,6 @@ export default { TextFieldModule, BadgeModule, ], - declarations: [A11yTitleDirective], providers: [ { provide: I18nService, diff --git a/libs/components/src/form/form.stories.ts b/libs/components/src/form/form.stories.ts index 6aef140fe5f..1fc9bbef751 100644 --- a/libs/components/src/form/form.stories.ts +++ b/libs/components/src/form/form.stories.ts @@ -1,13 +1,6 @@ -import { - AbstractControl, - FormBuilder, - FormsModule, - ReactiveFormsModule, - ValidationErrors, - ValidatorFn, - Validators, -} from "@angular/forms"; +import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from "@angular/forms"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { userEvent, getByText } from "@storybook/test"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -15,6 +8,7 @@ import { ButtonModule } from "../button"; import { CheckboxModule } from "../checkbox"; import { FormControlModule } from "../form-control"; import { FormFieldModule } from "../form-field"; +import { trimValidator, forbiddenCharacters } from "../form-field/bit-validators"; import { InputModule } from "../input/input.module"; import { MultiSelectModule } from "../multi-select"; import { RadioButtonModule } from "../radio-button"; @@ -48,13 +42,19 @@ export default { required: "required", checkboxRequired: "Option is required", inputRequired: "Input is required.", - inputEmail: "Input is not an email-address.", + inputEmail: "Input is not an email address.", + inputForbiddenCharacters: (char) => + `The following characters are not allowed: "${char}"`, inputMinValue: (min) => `Input value must be at least ${min}.`, inputMaxValue: (max) => `Input value must not exceed ${max}.`, + inputMinLength: (min) => `Input value must be at least ${min} characters long.`, + inputMaxLength: (max) => `Input value must not exceed ${max} characters in length.`, + inputTrimValidator: `Input must not contain only whitespace.`, multiSelectPlaceholder: "-- Type to Filter --", multiSelectLoading: "Retrieving options...", multiSelectNotFound: "No items found", multiSelectClearAll: "Clear all", + fieldsNeedAttention: "__$1__ field(s) above need your attention.", }); }, }, @@ -72,7 +72,7 @@ export default { const fb = new FormBuilder(); const exampleFormObj = fb.group({ name: ["", [Validators.required]], - email: ["", [Validators.required, Validators.email, forbiddenNameValidator(/bit/i)]], + email: ["", [Validators.required, Validators.email, forbiddenCharacters(["#"])]], country: [undefined as string | undefined, [Validators.required]], groups: [], terms: [false, [Validators.requiredTrue]], @@ -80,14 +80,6 @@ const exampleFormObj = fb.group({ age: [null, [Validators.min(0), Validators.max(150)]], }); -// Custom error message, `message` is shown as the error message -function forbiddenNameValidator(nameRe: RegExp): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - const forbidden = nameRe.test(control.value); - return forbidden ? { forbiddenName: { message: "forbiddenName" } } : null; - }; -} - type Story = StoryObj; export const FullExample: Story = { @@ -177,3 +169,95 @@ export const FullExample: Story = { ], }, }; + +const showValidationsFormObj = fb.group({ + required: ["", [Validators.required]], + whitespace: [" ", trimValidator], + email: ["example?bad-email", [Validators.email]], + minLength: ["Hello", [Validators.minLength(8)]], + maxLength: ["Hello there", [Validators.maxLength(8)]], + minValue: [9, [Validators.min(10)]], + maxValue: [15, [Validators.max(10)]], + forbiddenChars: ["Th!$ value cont#in$ forbidden char$", forbiddenCharacters(["#", "!", "$"])], +}); + +export const Validations: Story = { + render: (args) => ({ + props: { + formObj: showValidationsFormObj, + submit: () => showValidationsFormObj.markAllAsTouched(), + ...args, + }, + template: /*html*/ ` +
+ + Required validation + + This field is required. Submit form or blur input to see error + + + + Email validation + + This field contains a malformed email address. Submit form or blur input to see error + + + + Min length validation + + Value must be at least 8 characters. Submit form or blur input to see error + + + + Max length validation + + Value must be less then 8 characters. Submit form or blur input to see error + + + + Min number value validation + + Value must be greater than 10. Submit form or blur input to see error + + + + Max number value validation + + Value must be less than than 10. Submit form or blur input to see error + + + + Forbidden characters validation + + Value must not contain '#', '!' or '$'. Submit form or blur input to see error + + + + White space validation + + This input contains only white space. Submit form or blur input to see error + + + + +
+ `, + }), + play: async (context) => { + const canvas = context.canvasElement; + const submitButton = getByText(canvas, "Submit"); + + await userEvent.click(submitButton); + }, +}; diff --git a/libs/components/src/form/forms.mdx b/libs/components/src/form/forms.mdx index e3baf200f96..498eb8a3ed2 100644 --- a/libs/components/src/form/forms.mdx +++ b/libs/components/src/form/forms.mdx @@ -142,8 +142,20 @@ If a checkbox group has more than 4 options a +## Validation messages + +These are examples of our default validation error messages: + + + ## Accessibility +### Icon Buttons in Form Fields + +When adding prefix or suffix icon buttons to a form field, be sure to use the `appA11yTitle` +directive to provide a label for screenreaders. Typically, the label should follow this pattern: +`{Action} {field label}`, i.e. "Copy username". + ### Required Fields - Use "(required)" in the label of each required form field styled the same as the field's helper @@ -152,12 +164,6 @@ If a checkbox group has more than 4 options a helper text. - **Example:** "Billing Email is required if owned by a business". -### Icon Buttons in Form Fields - -When adding prefix or suffix icon buttons to a form field, be sure to use the `appA11yTitle` -directive to provide a label for screenreaders. Typically, the label should follow this pattern: -`{Action} {field label}`, i.e. "Copy username". - ### Form Field Errors - When a resting field is filled out, validation is triggered when the user de-focuses the field From ece5ebe844e3fd4e97210c196d5ff7d2c63682cb Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 2 Jul 2025 13:19:17 -0700 Subject: [PATCH 18/19] [PM-17663] Fix extension Fill button display on page load (#15359) * [PM-17663] Convert vault-list-items-container inputs to signals - Cleaned up some grouping logic - Cleaned up strict null checks and removed eslint comment * [PM-17663] Prefer undefined over null * [PM-17663] Fix flashing Fill buttons --- .../autofill-vault-list-items.component.html | 2 +- .../autofill-vault-list-items.component.ts | 10 +- .../vault-list-items-container.component.html | 38 ++--- .../vault-list-items-container.component.ts | 149 ++++++++---------- 4 files changed, 94 insertions(+), 105 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html index 19f1668eba4..47ef0284d6a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -4,7 +4,7 @@ [title]="((currentURIIsBlocked$ | async) ? 'itemSuggestions' : 'autofillSuggestions') | i18n" [showRefresh]="showRefresh" (onRefresh)="refreshCurrentTab()" - [description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : null" + [description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined" showAutofillButton [disableDescriptionMargin]="showEmptyAutofillTip$ | async" [primaryActionAutofill]="clickItemsToAutofillVaultView$ | async" diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts index 47f104cd4d3..aa790d24ede 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -1,7 +1,7 @@ import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; -import { combineLatest, map, Observable } from "rxjs"; +import { combineLatest, map, Observable, startWith } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; @@ -41,7 +41,9 @@ export class AutofillVaultListItemsComponent { /** Flag indicating whether the login item should automatically autofill when clicked */ protected clickItemsToAutofillVaultView$: Observable = - this.vaultSettingsService.clickItemsToAutofillVaultView$; + this.vaultSettingsService.clickItemsToAutofillVaultView$.pipe( + startWith(true), // Start with true to avoid flashing the fill button on first load + ); protected groupByType = toSignal( this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)), @@ -74,9 +76,7 @@ export class AutofillVaultListItemsComponent { private vaultPopupItemsService: VaultPopupItemsService, private vaultPopupAutofillService: VaultPopupAutofillService, private vaultSettingsService: VaultSettingsService, - ) { - // TODO: Migrate logic to show Autofill policy toast PM-8144 - } + ) {} /** * Refreshes the current tab to re-populate the autofill ciphers. diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index 87d13d4d18a..8dca1f9e576 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -1,8 +1,8 @@ - + - +