diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index db60ad6a93b..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 @@ -91,6 +92,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/.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 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..aa80222c672 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.", @@ -56,7 +56,8 @@ "unlimitedStorage", "webNavigation", "webRequest", - "webRequestBlocking" + "webRequestBlocking", + "notifications" ], "__safari__permissions": [ "", 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/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 @@ - + - + + + + `, + }), + 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 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/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/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), + ); + } } } } diff --git a/libs/vault/src/components/totp-countdown/totp-countdown.component.spec.ts b/libs/vault/src/components/totp-countdown/totp-countdown.component.spec.ts new file mode 100644 index 00000000000..58c03b3388f --- /dev/null +++ b/libs/vault/src/components/totp-countdown/totp-countdown.component.spec.ts @@ -0,0 +1,95 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { BitTotpCountdownComponent } from "./totp-countdown.component"; + +describe("BitTotpCountdownComponent", () => { + let component: BitTotpCountdownComponent; + let fixture: ComponentFixture; + let totpService: jest.Mocked; + + const mockCipher1 = { + id: "cipher-id", + name: "Test Cipher", + login: { totp: "totp-secret" }, + } as CipherView; + + const mockCipher2 = { + id: "cipher-id-2", + name: "Test Cipher 2", + login: { totp: "totp-secret-2" }, + } as CipherView; + + const mockTotpResponse1 = { + code: "123456", + period: 30, + }; + + const mockTotpResponse2 = { + code: "987654", + period: 10, + }; + + beforeEach(async () => { + totpService = mock({ + getCode$: jest.fn().mockImplementation((totp) => { + if (totp === mockCipher1.login.totp) { + return of(mockTotpResponse1); + } + + return of(mockTotpResponse2); + }), + }); + + await TestBed.configureTestingModule({ + providers: [{ provide: TotpService, useValue: totpService }], + }).compileComponents(); + + fixture = TestBed.createComponent(BitTotpCountdownComponent); + component = fixture.componentInstance; + component.cipher = mockCipher1; + fixture.detectChanges(); + }); + + it("initializes totpInfo$ observable", (done) => { + component.totpInfo$?.subscribe((info) => { + expect(info.totpCode).toBe(mockTotpResponse1.code); + expect(info.totpCodeFormatted).toBe("123 456"); + done(); + }); + }); + + it("emits sendCopyCode when TOTP code is available", (done) => { + const emitter = jest.spyOn(component.sendCopyCode, "emit"); + + component.totpInfo$?.subscribe((info) => { + expect(emitter).toHaveBeenCalledWith({ + totpCode: info.totpCode, + totpCodeFormatted: info.totpCodeFormatted, + }); + done(); + }); + }); + + it("updates totpInfo$ when cipher changes", (done) => { + component.cipher = mockCipher2; + component.ngOnChanges({ + cipher: { + currentValue: mockCipher2, + previousValue: mockCipher1, + firstChange: false, + isFirstChange: () => false, + }, + }); + + component.totpInfo$?.subscribe((info) => { + expect(info.totpCode).toBe(mockTotpResponse2.code); + expect(info.totpCodeFormatted).toBe("987 654"); + done(); + }); + }); +}); diff --git a/libs/vault/src/components/totp-countdown/totp-countdown.component.ts b/libs/vault/src/components/totp-countdown/totp-countdown.component.ts index c634b1165d9..5274ce621f0 100644 --- a/libs/vault/src/components/totp-countdown/totp-countdown.component.ts +++ b/libs/vault/src/components/totp-countdown/totp-countdown.component.ts @@ -1,7 +1,13 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { + Component, + EventEmitter, + Input, + OnInit, + Output, + OnChanges, + SimpleChanges, +} from "@angular/core"; import { Observable, map, tap } from "rxjs"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; @@ -14,8 +20,8 @@ import { TypographyModule } from "@bitwarden/components"; templateUrl: "totp-countdown.component.html", imports: [CommonModule, TypographyModule], }) -export class BitTotpCountdownComponent implements OnInit { - @Input() cipher: CipherView; +export class BitTotpCountdownComponent implements OnInit, OnChanges { + @Input({ required: true }) cipher!: CipherView; @Output() sendCopyCode = new EventEmitter(); /** @@ -26,6 +32,16 @@ export class BitTotpCountdownComponent implements OnInit { constructor(protected totpService: TotpService) {} async ngOnInit() { + this.setTotpInfo(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes["cipher"]) { + this.setTotpInfo(); + } + } + + private setTotpInfo(): void { this.totpInfo$ = this.cipher?.login?.totp ? this.totpService.getCode$(this.cipher.login.totp).pipe( map((response) => { diff --git a/package-lock.json b/package-lock.json index 176aa40a650..900dc0d0b00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -197,11 +197,11 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2025.6.0" + "version": "2025.6.1" }, "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", @@ -288,7 +288,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.6.1", + "version": "2025.7.0", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -353,6 +353,11 @@ "version": "0.0.0", "license": "GPL-3.0" }, + "libs/logging": { + "name": "@bitwarden/logging", + "version": "0.0.1", + "license": "GPL-3.0" + }, "libs/node": { "name": "@bitwarden/node", "version": "0.0.0", @@ -423,6 +428,11 @@ "version": "0.0.0", "license": "GPL-3.0" }, + "libs/user-core": { + "name": "@bitwarden/user-core", + "version": "0.0.0", + "license": "GPL-3.0" + }, "libs/vault": { "name": "@bitwarden/vault", "version": "0.0.0", @@ -4583,6 +4593,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 @@ -4632,6 +4646,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 b826d51e66e..c462ab97d37 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"], @@ -46,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"],