diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c77fa959047..c957ac0e22b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,7 +8,7 @@ apps/desktop/desktop_native @bitwarden/team-platform-dev apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev -apps/desktop/desktop_native/macos_provider @bitwarden/team-autofill-desktop-dev +apps/desktop/desktop_native/autofill_provider @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-management-dev ## No ownership for Cargo.lock and Cargo.toml to allow dependency updates @@ -84,6 +84,7 @@ apps/web/src/app/billing @bitwarden/team-billing-dev libs/angular/src/billing @bitwarden/team-billing-dev libs/common/src/billing @bitwarden/team-billing-dev libs/billing @bitwarden/team-billing-dev +libs/pricing @bitwarden/team-billing-dev bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev ## Platform team files ## @@ -223,6 +224,9 @@ apps/web/src/locales/en/messages.json **/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre **/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre +# Scanning tools +.checkmarx/ @bitwarden/team-appsec + ## Overrides # For the time being platform owns tsconfig and jest config # These overrides will be removed after Nx is implemented @@ -230,7 +234,9 @@ apps/web/src/locales/en/messages.json **/tsconfig.json @bitwarden/team-platform-dev **/jest.config.js @bitwarden/team-platform-dev **/project.jsons @bitwarden/team-platform-dev -libs/pricing @bitwarden/team-billing-dev +# Platform override specifically for the package-lock.json in +# native-messaging-test-runner so that Platform can manage all lock file updates +apps/desktop/native-messaging-test-runner/package-lock.json @bitwarden/team-platform-dev # Claude related files .claude/ @bitwarden/team-ai-sme diff --git a/.github/renovate.json5 b/.github/renovate.json5 index d1a0ef12f4b..b0ede711f4c 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -189,6 +189,7 @@ "semver", "serde", "serde_json", + "serde_with", "simplelog", "style-loader", "sysinfo", diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 7b35baf01e2..ef2c91f0a7d 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -152,7 +152,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -260,7 +260,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -392,7 +392,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index d0abe8e12e7..75820c54977 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -130,7 +130,7 @@ jobs: } >> "$GITHUB_ENV" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -326,7 +326,7 @@ jobs: choco install nasm --no-progress - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 0d4009e54f9..c021dedd8e1 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -183,7 +183,7 @@ jobs: uses: bitwarden/gh-actions/free-disk-space@main - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -339,7 +339,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -487,7 +487,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -755,7 +755,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1000,7 +1000,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1240,7 +1240,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1515,7 +1515,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index c7d80b82baa..6189744fe67 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -58,7 +58,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 81d79df569c..7862c14c186 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -64,7 +64,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -142,7 +142,7 @@ jobs: run: cargo +nightly udeps --workspace --all-features --all-targets - name: Install cargo-deny - uses: taiki-e/install-action@2e9d707ef49c9b094d45955b60c7e5c0dfedeb14 # v2.66.5 + uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2.66.7 with: tool: cargo-deny@0.18.6 diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 3a7431c07f0..e468ead4f1e 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index ef287b0de08..5f6ee83e41f 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -216,7 +216,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} registry-url: "https://registry.npmjs.org/" diff --git a/.github/workflows/sdk-breaking-change-check.yml b/.github/workflows/sdk-breaking-change-check.yml index 765e900af5c..eab0dffeda4 100644 --- a/.github/workflows/sdk-breaking-change-check.yml +++ b/.github/workflows/sdk-breaking-change-check.yml @@ -76,7 +76,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eedf991d826..41b75c5a31d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/apps/browser/config/base.json b/apps/browser/config/base.json index 02bdc5d22af..0de1da0a648 100644 --- a/apps/browser/config/base.json +++ b/apps/browser/config/base.json @@ -1,7 +1,6 @@ { "devFlags": {}, "flags": { - "accountSwitching": false, "sdk": true } } diff --git a/apps/browser/config/development.json b/apps/browser/config/development.json index 042a98c2c39..12a34d8cbee 100644 --- a/apps/browser/config/development.json +++ b/apps/browser/config/development.json @@ -4,8 +4,5 @@ "base": "https://localhost:8080" }, "skipWelcomeOnInstall": true - }, - "flags": { - "accountSwitching": true } } diff --git a/apps/browser/config/production.json b/apps/browser/config/production.json deleted file mode 100644 index a43eee1d5c9..00000000000 --- a/apps/browser/config/production.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "flags": { - "accountSwitching": true - } -} diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 8e2c3279687..4c36a852f6a 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -4610,11 +4619,11 @@ "message": "URI match detection is how Bitwarden identifies autofill suggestions.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, - "regExAdvancedOptionWarning": { + "regExAdvancedOptionWarning": { "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, - "startsWithAdvancedOptionWarning": { + "startsWithAdvancedOptionWarning": { "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, @@ -4622,7 +4631,7 @@ "message": "More about match detection", "description": "Link to match detection docs on warning dialog for advance match strategy" }, - "uriAdvancedOption":{ + "uriAdvancedOption": { "message": "Advanced options", "description": "Advanced option placeholder for uri option component" }, @@ -4812,7 +4821,7 @@ } } }, - "copyFieldCipherName": { + "copyFieldCipherName": { "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { @@ -4844,7 +4853,7 @@ "adminConsole": { "message": "Admin Console" }, - "admin" :{ + "admin": { "message": "Admin" }, "automaticUserConfirmation": { @@ -4853,7 +4862,7 @@ "automaticUserConfirmationHint": { "message": "Automatically confirm pending users while this device is unlocked" }, - "autoConfirmOnboardingCallout":{ + "autoConfirmOnboardingCallout": { "message": "Save time with automatic user confirmation" }, "autoConfirmWarning": { @@ -5793,7 +5802,7 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitleV2":{ + "phishingPageTitleV2": { "message": "Phishing attempt detected" }, "phishingPageSummary": { @@ -5813,7 +5822,7 @@ "message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." }, - "phishingPageLearnMore" : { + "phishingPageLearnMore": { "message": "Learn more about phishing detection" }, "protectedBy": { @@ -5981,7 +5990,7 @@ "cardNumberLabel": { "message": "Card number" }, - "removeMasterPasswordForOrgUserKeyConnector":{ + "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, "continueWithLogIn": { @@ -5999,10 +6008,10 @@ "verifyYourOrganization": { "message": "Verify your organization to log in" }, - "organizationVerified":{ + "organizationVerified": { "message": "Organization verified" }, - "domainVerified":{ + "domainVerified": { "message": "Domain verified" }, "leaveOrganizationContent": { @@ -6120,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.html b/apps/browser/src/auth/popup/account-switching/account-switcher.component.html index cef2a748d58..0a9e2a1dd9d 100644 --- a/apps/browser/src/auth/popup/account-switching/account-switcher.component.html +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.html @@ -7,13 +7,16 @@ - + -
+
- +

{{ "availableAccounts" | i18n }}

diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts index d7d3c02ab14..ae7f66a9018 100644 --- a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts @@ -1,7 +1,7 @@ import { CommonModule, Location } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { Subject, firstValueFrom, map, of, startWith, switchMap } from "rxjs"; +import { Observable, Subject, firstValueFrom, map, of, startWith, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { LockService, LogoutService } from "@bitwarden/auth/common"; @@ -24,7 +24,6 @@ import { TypographyModule, } from "@bitwarden/components"; -import { enableAccountSwitching } from "../../../platform/flags"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; @@ -59,7 +58,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { loading = false; activeUserCanLock = false; - enableAccountSwitching = true; + enableAccountSwitching$: Observable; constructor( private accountSwitcherService: AccountSwitcherService, @@ -72,7 +71,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { private authService: AuthService, private lockService: LockService, private logoutService: LogoutService, - ) {} + ) { + this.enableAccountSwitching$ = this.accountSwitcherService.accountSwitchingEnabled$(); + } get accountLimit() { return this.accountSwitcherService.ACCOUNT_LIMIT; @@ -97,19 +98,21 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { switchMap((accounts) => { // If account switching is disabled, don't show the lock all button // as only one account should be shown. - if (!enableAccountSwitching()) { - return of(false); - } + return this.accountSwitcherService.accountSwitchingEnabled$().pipe( + switchMap((enabled) => { + if (!enabled) { + return of(false); + } - // When there are an inactive accounts provide the option to lock all accounts - // Note: "Add account" is counted as an inactive account, so check for more than one account - return of(accounts.length > 1); + // When there are inactive accounts provide the option to lock all accounts + // Note: "Add account" is counted as an inactive account, so check for more than one account + return of(accounts.length > 1); + }), + ); }), ); async ngOnInit() { - this.enableAccountSwitching = enableAccountSwitching(); - const availableVaultTimeoutActions = await firstValueFrom( this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(), ); diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts index f3be535f00e..13f4a8635df 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts @@ -9,6 +9,7 @@ import { import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Environment, EnvironmentService, @@ -37,6 +38,7 @@ describe("AccountSwitcherService", () => { const environmentService = mock(); const logService = mock(); const authService = mock(); + const configService = mock(); let accountSwitcherService: AccountSwitcherService; @@ -60,6 +62,7 @@ describe("AccountSwitcherService", () => { messagingService, environmentService, logService, + configService, authService, ); }); diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts index 99d2c83283e..0f25ea91c99 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts @@ -7,6 +7,7 @@ import { filter, firstValueFrom, map, + of, switchMap, throwError, timeout, @@ -17,11 +18,14 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { BrowserApi } from "../../../../platform/browser/browser-api"; import { fromChromeEvent } from "../../../../platform/browser/from-chrome-event"; export type AvailableAccount = { @@ -52,6 +56,7 @@ export class AccountSwitcherService { private messagingService: MessagingService, private environmentService: EnvironmentService, private logService: LogService, + private configService: ConfigService, authService: AuthService, ) { this.availableAccounts$ = combineLatest([ @@ -123,6 +128,19 @@ export class AccountSwitcherService { ); } + /* + * PM-5594: This was a compile-time flag (default true) which made an exception for Safari in platform/flags. + * The truthiness of AccountSwitching has been enshrined at this point, so those compile-time flags have been removed + * in favor of this method to allow easier access to the config service for controlling Safari. Unwinding the Safari + * flag should be more straightforward from this consolidation. + */ + accountSwitchingEnabled$(): Observable { + if (BrowserApi.isSafariApi) { + return this.configService.getFeatureFlag$(FeatureFlag.SafariAccountSwitching); + } + return of(true); + } + get specialAccountAddId() { return this.SPECIAL_ADD_ACCOUNT_ID; } diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 6a3378670bf..1789feebe4e 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -257,7 +257,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { pin: await this.pinService.isPinSet(activeAccount.id), pinLockWithMasterPassword: (await this.pinService.getPinLockType(activeAccount.id)) == "EPHEMERAL", - biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(), + biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(activeAccount.id), enableAutoBiometricsPrompt: await firstValueFrom( this.biometricStateService.promptAutomatically$, ), diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts index 1cd614a9516..d55e0827352 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts @@ -1,12 +1,10 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { WebauthnUtils } from "../utils/webauthn-utils"; import { MessageTypes } from "./messaging/message"; import { Messenger } from "./messaging/messenger"; (function (globalContext) { - if (globalContext.document.currentScript) { + if (globalContext.document.currentScript?.parentNode) { globalContext.document.currentScript.parentNode.removeChild( globalContext.document.currentScript, ); @@ -86,7 +84,7 @@ import { Messenger } from "./messaging/messenger"; */ async function createWebAuthnCredential( options?: CredentialCreationOptions, - ): Promise { + ): Promise { if (!isWebauthnCall(options)) { return await browserCredentials.create(options); } @@ -106,13 +104,18 @@ import { Messenger } from "./messaging/messenger"; options?.signal, ); - if (response.type !== MessageTypes.CredentialCreationResponse) { + if (response.type !== MessageTypes.CredentialCreationResponse || !response.result) { throw new Error("Something went wrong."); } return WebauthnUtils.mapCredentialRegistrationResult(response.result); } catch (error) { - if (error && error.fallbackRequested && fallbackSupported) { + if ( + fallbackSupported && + error instanceof Object && + "fallbackRequested" in error && + error.fallbackRequested + ) { await waitForFocus(); return await browserCredentials.create(options); } @@ -127,7 +130,9 @@ import { Messenger } from "./messaging/messenger"; * @param options Options for creating new credentials. * @returns Promise that resolves to the new credential object. */ - async function getWebAuthnCredential(options?: CredentialRequestOptions): Promise { + async function getWebAuthnCredential( + options?: CredentialRequestOptions, + ): Promise { if (!isWebauthnCall(options)) { return await browserCredentials.get(options); } @@ -153,7 +158,7 @@ import { Messenger } from "./messaging/messenger"; internalAbortController.signal, ); internalAbortController.signal.removeEventListener("abort", abortListener); - if (response.type !== MessageTypes.CredentialGetResponse) { + if (response.type !== MessageTypes.CredentialGetResponse || !response.result) { throw new Error("Something went wrong."); } @@ -176,7 +181,7 @@ import { Messenger } from "./messaging/messenger"; abortSignal.removeEventListener("abort", abortListener); internalAbortControllers.forEach((controller) => controller.abort()); - return response; + return response ?? null; } try { @@ -188,13 +193,18 @@ import { Messenger } from "./messaging/messenger"; options?.signal, ); - if (response.type !== MessageTypes.CredentialGetResponse) { + if (response.type !== MessageTypes.CredentialGetResponse || !response.result) { throw new Error("Something went wrong."); } return WebauthnUtils.mapCredentialAssertResult(response.result); } catch (error) { - if (error && error.fallbackRequested && fallbackSupported) { + if ( + fallbackSupported && + error instanceof Object && + "fallbackRequested" in error && + error.fallbackRequested + ) { await waitForFocus(); return await browserCredentials.get(options); } @@ -203,8 +213,10 @@ import { Messenger } from "./messaging/messenger"; } } - function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) { - return options && "publicKey" in options; + function isWebauthnCall( + options?: CredentialCreationOptions | CredentialRequestOptions, + ): options is CredentialCreationOptions | CredentialRequestOptions { + return options != null && "publicKey" in options; } /** @@ -217,7 +229,7 @@ import { Messenger } from "./messaging/messenger"; */ async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) { try { - if (globalContext.top.document.hasFocus()) { + if (globalContext.top?.document.hasFocus()) { return; } } catch { @@ -225,9 +237,14 @@ import { Messenger } from "./messaging/messenger"; return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait)); } + if (!globalContext.top) { + return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait)); + } + + const topWindow = globalContext.top; const focusPromise = new Promise((resolve) => { focusListenerHandler = () => resolve(); - globalContext.top.addEventListener("focus", focusListenerHandler); + topWindow.addEventListener("focus", focusListenerHandler); }); const timeoutPromise = new Promise((_, reject) => { @@ -248,7 +265,7 @@ import { Messenger } from "./messaging/messenger"; } function clearWaitForFocus() { - globalContext.top.removeEventListener("focus", focusListenerHandler); + globalContext.top?.removeEventListener("focus", focusListenerHandler); if (waitForFocusTimeout) { globalContext.clearTimeout(waitForFocusTimeout); } diff --git a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts index 257f7e9efd5..78bb9aa8f33 100644 --- a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts +++ b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Message, MessageTypes } from "./message"; const SENDER = "bitwarden-webauthn"; @@ -25,7 +23,9 @@ type Handler = ( * handling aborts and exceptions across separate execution contexts. */ export class Messenger { - private messageEventListener: (event: MessageEvent) => void | null = null; + private messageEventListener: + | ((event: MessageEvent) => void | Promise) + | null = null; private onDestroy = new EventTarget(); /** diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html index 75be3bcc1a0..30170820a27 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html @@ -8,7 +8,9 @@

{{ - accountSwitcherEnabled ? ("excludedDomainsDescAlt" | i18n) : ("excludedDomainsDesc" | i18n) + (accountSwitcherEnabled$ | async) + ? ("excludedDomainsDescAlt" | i18n) + : ("excludedDomainsDesc" | i18n) }}

diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts index e67c826cac6..6714f749d2d 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts @@ -15,7 +15,7 @@ import { FormArray, } from "@angular/forms"; import { RouterModule } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { Observable, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; @@ -35,7 +35,7 @@ import { TypographyModule, } from "@bitwarden/components"; -import { enableAccountSwitching } from "../../../platform/flags"; +import { AccountSwitcherService } from "../../../auth/popup/account-switching/services/account-switcher.service"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; @@ -74,7 +74,8 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { @ViewChildren("uriInput") uriInputElements: QueryList> = new QueryList(); - accountSwitcherEnabled = false; + readonly accountSwitcherEnabled$: Observable = + this.accountSwitcherService.accountSwitchingEnabled$(); dataIsPristine = true; isLoading = false; excludedDomainsState: string[] = []; @@ -95,9 +96,8 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { private toastService: ToastService, private formBuilder: FormBuilder, private popupRouterCacheService: PopupRouterCacheService, - ) { - this.accountSwitcherEnabled = enableAccountSwitching(); - } + private accountSwitcherService: AccountSwitcherService, + ) {} get domainForms() { return this.domainListForm.get("domains") as FormArray; diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 66a692dbe20..58f3ad11166 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -158,7 +158,7 @@ describe("CollectAutofillContentService", () => { type: "text", value: "", checked: false, - autoCompleteType: "", + autoCompleteType: null, disabled: false, readonly: false, selectInfo: null, @@ -346,7 +346,7 @@ describe("CollectAutofillContentService", () => { type: "text", value: "", checked: false, - autoCompleteType: "", + autoCompleteType: null, disabled: false, readonly: false, selectInfo: null, @@ -379,7 +379,7 @@ describe("CollectAutofillContentService", () => { type: "password", value: "", checked: false, - autoCompleteType: "", + autoCompleteType: null, disabled: false, readonly: false, selectInfo: null, @@ -588,7 +588,7 @@ describe("CollectAutofillContentService", () => { "aria-disabled": false, "aria-haspopup": false, "aria-hidden": false, - autoCompleteType: "", + autoCompleteType: null, checked: false, "data-stripe": null, disabled: false, @@ -621,7 +621,7 @@ describe("CollectAutofillContentService", () => { "aria-disabled": false, "aria-haspopup": false, "aria-hidden": false, - autoCompleteType: "", + autoCompleteType: null, checked: false, "data-stripe": null, disabled: false, @@ -2507,9 +2507,7 @@ describe("CollectAutofillContentService", () => { "class", "tabindex", "title", - "value", "rel", - "tagname", "checked", "disabled", "readonly", diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 117c7c5e2a4..1d464e1313f 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AUTOFILL_ATTRIBUTES } from "@bitwarden/common/autofill/constants"; + import AutofillField from "../models/autofill-field"; import AutofillForm from "../models/autofill-form"; import AutofillPageDetails from "../models/autofill-page-details"; @@ -242,10 +244,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this._autofillFormElements.set(formElement, { opid: formElement.opid, htmlAction: this.getFormActionAttribute(formElement), - htmlName: this.getPropertyOrAttribute(formElement, "name"), - htmlClass: this.getPropertyOrAttribute(formElement, "class"), - htmlID: this.getPropertyOrAttribute(formElement, "id"), - htmlMethod: this.getPropertyOrAttribute(formElement, "method"), + htmlName: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.NAME), + htmlClass: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.CLASS), + htmlID: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.ID), + htmlMethod: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.METHOD), }); } @@ -260,7 +262,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ * @private */ private getFormActionAttribute(element: ElementWithOpId): string { - return new URL(this.getPropertyOrAttribute(element, "action"), globalThis.location.href).href; + return new URL( + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ACTION), + globalThis.location.href, + ).href; } /** @@ -335,7 +340,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ return priorityFormFields; } - const fieldType = this.getPropertyOrAttribute(element, "type")?.toLowerCase(); + const fieldType = this.getPropertyOrAttribute( + element, + AUTOFILL_ATTRIBUTES.TYPE, + )?.toLowerCase(); if (unimportantFieldTypesSet.has(fieldType)) { unimportantFormFields.push(element); continue; @@ -384,11 +392,11 @@ export class CollectAutofillContentService implements CollectAutofillContentServ elementNumber: index, maxLength: this.getAutofillFieldMaxLength(element), viewable: await this.domElementVisibilityService.isElementViewable(element), - htmlID: this.getPropertyOrAttribute(element, "id"), - htmlName: this.getPropertyOrAttribute(element, "name"), - htmlClass: this.getPropertyOrAttribute(element, "class"), - tabindex: this.getPropertyOrAttribute(element, "tabindex"), - title: this.getPropertyOrAttribute(element, "title"), + htmlID: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ID), + htmlName: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.NAME), + htmlClass: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.CLASS), + tabindex: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.TABINDEX), + title: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.TITLE), tagName: this.getAttributeLowerCase(element, "tagName"), dataSetValues: this.getDataSetValues(element), }; @@ -404,16 +412,16 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } let autofillFieldLabels = {}; - const elementType = this.getAttributeLowerCase(element, "type"); + const elementType = this.getAttributeLowerCase(element, AUTOFILL_ATTRIBUTES.TYPE); if (elementType !== "hidden") { autofillFieldLabels = { "label-tag": this.createAutofillFieldLabelTag(element as FillableFormFieldElement), - "label-data": this.getPropertyOrAttribute(element, "data-label"), - "label-aria": this.getPropertyOrAttribute(element, "aria-label"), + "label-data": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.DATA_LABEL), + "label-aria": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ARIA_LABEL), "label-top": this.createAutofillFieldTopLabel(element), "label-right": this.createAutofillFieldRightLabel(element), "label-left": this.createAutofillFieldLeftLabel(element), - placeholder: this.getPropertyOrAttribute(element, "placeholder"), + placeholder: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.PLACEHOLDER), }; } @@ -421,21 +429,21 @@ export class CollectAutofillContentService implements CollectAutofillContentServ const autofillField = { ...autofillFieldBase, ...autofillFieldLabels, - rel: this.getPropertyOrAttribute(element, "rel"), + rel: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.REL), type: elementType, value: this.getElementValue(element), - checked: this.getAttributeBoolean(element, "checked"), + checked: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.CHECKED), autoCompleteType: this.getAutoCompleteAttribute(element), - disabled: this.getAttributeBoolean(element, "disabled"), - readonly: this.getAttributeBoolean(element, "readonly"), + disabled: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.DISABLED), + readonly: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.READONLY), selectInfo: elementIsSelectElement(element) ? this.getSelectElementOptions(element as HTMLSelectElement) : null, form: fieldFormElement ? this.getPropertyOrAttribute(fieldFormElement, "opid") : null, - "aria-hidden": this.getAttributeBoolean(element, "aria-hidden", true), - "aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true), - "aria-haspopup": this.getAttributeBoolean(element, "aria-haspopup", true), - "data-stripe": this.getPropertyOrAttribute(element, "data-stripe"), + "aria-hidden": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_HIDDEN, true), + "aria-disabled": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_DISABLED, true), + "aria-haspopup": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_HASPOPUP, true), + "data-stripe": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.DATA_STRIPE), }; this.cacheAutofillFieldElement(index, element, autofillField); @@ -467,9 +475,9 @@ export class CollectAutofillContentService implements CollectAutofillContentServ */ private getAutoCompleteAttribute(element: ElementWithOpId): string { return ( - this.getPropertyOrAttribute(element, "x-autocompletetype") || - this.getPropertyOrAttribute(element, "autocompletetype") || - this.getPropertyOrAttribute(element, "autocomplete") + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.AUTOCOMPLETE) || + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.X_AUTOCOMPLETE_TYPE) || + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.AUTOCOMPLETE_TYPE) ); } @@ -957,6 +965,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.mutationObserver = new MutationObserver(this.handleMutationObserverMutation); this.mutationObserver.observe(document.documentElement, { attributes: true, + /** Mutations to node attributes NOT on this list will not be observed! */ + attributeFilter: Object.values(AUTOFILL_ATTRIBUTES), childList: true, subtree: true, }); @@ -1321,6 +1331,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ action: () => (dataTarget.htmlAction = this.getFormActionAttribute(element)), name: () => updateAttribute("htmlName"), id: () => updateAttribute("htmlID"), + class: () => updateAttribute("htmlClass"), method: () => updateAttribute("htmlMethod"), }; @@ -1350,29 +1361,49 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey }); }; const updateActions: Record = { - maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)), - id: () => updateAttribute("htmlID"), - name: () => updateAttribute("htmlName"), - class: () => updateAttribute("htmlClass"), - tabindex: () => updateAttribute("tabindex"), - title: () => updateAttribute("tabindex"), - rel: () => updateAttribute("rel"), - tagname: () => (dataTarget.tagName = this.getAttributeLowerCase(element, "tagName")), - type: () => (dataTarget.type = this.getAttributeLowerCase(element, "type")), - value: () => (dataTarget.value = this.getElementValue(element)), - checked: () => (dataTarget.checked = this.getAttributeBoolean(element, "checked")), - disabled: () => (dataTarget.disabled = this.getAttributeBoolean(element, "disabled")), - readonly: () => (dataTarget.readonly = this.getAttributeBoolean(element, "readonly")), - autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), - "data-label": () => updateAttribute("label-data"), + "aria-describedby": () => updateAttribute(AUTOFILL_ATTRIBUTES.ARIA_DESCRIBEDBY), "aria-label": () => updateAttribute("label-aria"), + "aria-labelledby": () => updateAttribute(AUTOFILL_ATTRIBUTES.ARIA_LABELLEDBY), "aria-hidden": () => - (dataTarget["aria-hidden"] = this.getAttributeBoolean(element, "aria-hidden", true)), + (dataTarget["aria-hidden"] = this.getAttributeBoolean( + element, + AUTOFILL_ATTRIBUTES.ARIA_HIDDEN, + true, + )), "aria-disabled": () => - (dataTarget["aria-disabled"] = this.getAttributeBoolean(element, "aria-disabled", true)), + (dataTarget["aria-disabled"] = this.getAttributeBoolean( + element, + AUTOFILL_ATTRIBUTES.ARIA_DISABLED, + true, + )), "aria-haspopup": () => - (dataTarget["aria-haspopup"] = this.getAttributeBoolean(element, "aria-haspopup", true)), - "data-stripe": () => updateAttribute("data-stripe"), + (dataTarget["aria-haspopup"] = this.getAttributeBoolean( + element, + AUTOFILL_ATTRIBUTES.ARIA_HASPOPUP, + true, + )), + autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + autocompletetype: () => + (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + "x-autocompletetype": () => + (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + class: () => updateAttribute("htmlClass"), + checked: () => + (dataTarget.checked = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.CHECKED)), + "data-label": () => updateAttribute("label-data"), + "data-stripe": () => updateAttribute(AUTOFILL_ATTRIBUTES.DATA_STRIPE), + disabled: () => + (dataTarget.disabled = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.DISABLED)), + id: () => updateAttribute("htmlID"), + maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)), + name: () => updateAttribute("htmlName"), + placeholder: () => updateAttribute(AUTOFILL_ATTRIBUTES.PLACEHOLDER), + readonly: () => + (dataTarget.readonly = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.READONLY)), + rel: () => updateAttribute(AUTOFILL_ATTRIBUTES.REL), + tabindex: () => updateAttribute(AUTOFILL_ATTRIBUTES.TABINDEX), + title: () => updateAttribute(AUTOFILL_ATTRIBUTES.TITLE), + type: () => (dataTarget.type = this.getAttributeLowerCase(element, AUTOFILL_ATTRIBUTES.TYPE)), }; if (!updateActions[attributeName]) { diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 660fcb97bcf..8d741039b31 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1031,6 +1031,8 @@ export default class MainBackground { this.keyGenerationService, this.sendStateProvider, this.encryptService, + this.cryptoFunctionService, + this.configService, ); this.sendApiService = new SendApiService( this.apiService, diff --git a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts index d8e9895237c..419de04d9f4 100644 --- a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts +++ b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts @@ -10,7 +10,7 @@ import { ButtonModule, CheckboxModule, FormFieldModule, - IconModule, + SvgModule, IconTileComponent, LinkModule, CalloutComponent, @@ -31,7 +31,7 @@ import { templateUrl: "phishing-warning.component.html", imports: [ CommonModule, - IconModule, + SvgModule, JslibModule, LinkModule, FormFieldModule, diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts index d633c0612f5..0cbb765ce0e 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts @@ -40,6 +40,7 @@ describe("PhishingDataService", () => { // Set default mock behaviors mockIndexedDbService.hasUrl.mockResolvedValue(false); mockIndexedDbService.loadAllUrls.mockResolvedValue([]); + mockIndexedDbService.findMatchingUrl.mockResolvedValue(false); mockIndexedDbService.saveUrls.mockResolvedValue(undefined); mockIndexedDbService.addUrls.mockResolvedValue(undefined); mockIndexedDbService.saveUrlsFromStream.mockResolvedValue(undefined); @@ -90,7 +91,7 @@ describe("PhishingDataService", () => { it("should NOT detect QA test addresses - different subpath", async () => { mockIndexedDbService.hasUrl.mockResolvedValue(false); - mockIndexedDbService.loadAllUrls.mockResolvedValue([]); + mockIndexedDbService.findMatchingUrl.mockResolvedValue(false); const url = new URL("https://phishing.testcategory.com/other"); const result = await service.isPhishingWebAddress(url); @@ -120,83 +121,138 @@ describe("PhishingDataService", () => { expect(result).toBe(true); expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/testing-param"); // Should not fall back to custom matcher when hasUrl returns true - expect(mockIndexedDbService.loadAllUrls).not.toHaveBeenCalled(); + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); - it("should fall back to custom matcher when hasUrl returns false", async () => { + it("should return false when hasUrl returns false (custom matcher disabled)", async () => { // Mock hasUrl to return false (no direct href match) mockIndexedDbService.hasUrl.mockResolvedValue(false); - // Mock loadAllUrls to return phishing URLs for custom matcher - mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/path"]); const url = new URL("http://phish.com/path"); const result = await service.isPhishingWebAddress(url); - expect(result).toBe(true); + // Custom matcher is currently disabled (useCustomMatcher: false), so result is false + expect(result).toBe(false); expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/path"); - expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled(); + // Custom matcher should NOT be called since it's disabled + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); it("should not detect a safe web address", async () => { // Mock hasUrl to return false mockIndexedDbService.hasUrl.mockResolvedValue(false); - // Mock loadAllUrls to return phishing URLs that don't match - mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com", "http://badguy.net"]); const url = new URL("http://safe.com"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(false); expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://safe.com/"); - expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled(); + // Custom matcher is disabled, so findMatchingUrl should NOT be called + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); - it("should not match against root web address with subpaths using custom matcher", async () => { + it("should not match against root web address with subpaths (custom matcher disabled)", async () => { // Mock hasUrl to return false (no direct href match) mockIndexedDbService.hasUrl.mockResolvedValue(false); - // Mock loadAllUrls to return entry that matches with subpath - mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/login"]); const url = new URL("http://phish.com/login/page"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(false); expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page"); - expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled(); + // Custom matcher is disabled, so findMatchingUrl should NOT be called + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); - it("should not match against root web address with different subpaths using custom matcher", async () => { + it("should not match against root web address with different subpaths (custom matcher disabled)", async () => { // Mock hasUrl to return false (no direct hostname match) mockIndexedDbService.hasUrl.mockResolvedValue(false); - // Mock loadAllUrls to return entry that matches with subpath - mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/login/page1"]); const url = new URL("http://phish.com/login/page2"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(false); expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page2"); - expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled(); + // Custom matcher is disabled, so findMatchingUrl should NOT be called + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); it("should handle IndexedDB errors gracefully", async () => { // Mock hasUrl to throw error mockIndexedDbService.hasUrl.mockRejectedValue(new Error("hasUrl error")); - // Mock loadAllUrls to also throw error - mockIndexedDbService.loadAllUrls.mockRejectedValue(new Error("IndexedDB error")); const url = new URL("http://phish.com/about"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(false); expect(logService.error).toHaveBeenCalledWith( - "[PhishingDataService] IndexedDB lookup via hasUrl failed", - expect.any(Error), - ); - expect(logService.error).toHaveBeenCalledWith( - "[PhishingDataService] Error running custom matcher", + "[PhishingDataService] IndexedDB lookup failed", expect.any(Error), ); + // Custom matcher is disabled, so no custom matcher error is expected + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); + }); + + it("should use cursor-based search when useCustomMatcher is enabled", async () => { + // Temporarily enable custom matcher for this test + const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER; + (PhishingDataService as any).USE_CUSTOM_MATCHER = true; + + try { + // Mock hasUrl to return false (no direct match) + mockIndexedDbService.hasUrl.mockResolvedValue(false); + // Mock findMatchingUrl to return true (custom matcher finds it) + mockIndexedDbService.findMatchingUrl.mockResolvedValue(true); + + const url = new URL("http://phish.com/path"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(true); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalled(); + expect(mockIndexedDbService.findMatchingUrl).toHaveBeenCalled(); + } finally { + // Restore original value + (PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue; + } + }); + + it("should return false when custom matcher finds no match (when enabled)", async () => { + const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER; + (PhishingDataService as any).USE_CUSTOM_MATCHER = true; + + try { + mockIndexedDbService.hasUrl.mockResolvedValue(false); + mockIndexedDbService.findMatchingUrl.mockResolvedValue(false); + + const url = new URL("http://safe.com/path"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(false); + expect(mockIndexedDbService.findMatchingUrl).toHaveBeenCalled(); + } finally { + (PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue; + } + }); + + it("should handle custom matcher errors gracefully (when enabled)", async () => { + const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER; + (PhishingDataService as any).USE_CUSTOM_MATCHER = true; + + try { + mockIndexedDbService.hasUrl.mockResolvedValue(false); + mockIndexedDbService.findMatchingUrl.mockRejectedValue(new Error("Cursor error")); + + const url = new URL("http://error.com/path"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingDataService] Custom matcher failed", + expect.any(Error), + ); + } finally { + (PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue; + } }); }); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts index 10268fa7f93..03759ba14bc 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -78,6 +78,10 @@ export const PHISHING_DOMAINS_BLOB_KEY = new KeyDefinition( /** Coordinates fetching, caching, and patching of known phishing web addresses */ export class PhishingDataService { + // Cursor-based search is disabled due to performance (6+ minutes on large databases) + // Enable when performance is optimized via indexing or other improvements + private static readonly USE_CUSTOM_MATCHER = false; + // While background scripts do not necessarily need destroying, // processes in PhishingDataService are memory intensive. // We are adding the destroy to guard against accidental leaks. @@ -153,8 +157,14 @@ export class PhishingDataService { * @returns True if the URL is a known phishing web address, false otherwise */ async isPhishingWebAddress(url: URL): Promise { + // Skip non-http(s) protocols - phishing database only contains web URLs + if (url.protocol !== "http:" && url.protocol !== "https:") { + return false; + } + // Quick check for QA/dev test addresses if (this._testWebAddresses.includes(url.href)) { + this.logService.info("[PhishingDataService] Found test web address: " + url.href); return true; } @@ -162,28 +172,41 @@ export class PhishingDataService { try { // Quick lookup: check direct presence of href in IndexedDB - const hasUrl = await this.indexedDbService.hasUrl(url.href); + // Also check without trailing slash since browsers add it but DB entries may not have it + const urlHref = url.href; + const urlWithoutTrailingSlash = urlHref.endsWith("/") ? urlHref.slice(0, -1) : null; + + let hasUrl = await this.indexedDbService.hasUrl(urlHref); + + if (!hasUrl && urlWithoutTrailingSlash) { + hasUrl = await this.indexedDbService.hasUrl(urlWithoutTrailingSlash); + } + if (hasUrl) { + this.logService.info("[PhishingDataService] Found phishing URL: " + urlHref); return true; } } catch (err) { - this.logService.error("[PhishingDataService] IndexedDB lookup via hasUrl failed", err); + this.logService.error("[PhishingDataService] IndexedDB lookup failed", err); } - // If a custom matcher is provided, iterate stored entries and apply the matcher. - if (resource && resource.match) { + // Custom matcher is disabled for performance (see USE_CUSTOM_MATCHER) + if (resource && resource.match && PhishingDataService.USE_CUSTOM_MATCHER) { try { - const entries = await this.indexedDbService.loadAllUrls(); - for (const entry of entries) { - if (resource.match(url, entry)) { - return true; - } + const found = await this.indexedDbService.findMatchingUrl((entry) => + resource.match(url, entry), + ); + + if (found) { + this.logService.info("[PhishingDataService] Found phishing URL via matcher: " + url.href); } + return found; } catch (err) { - this.logService.error("[PhishingDataService] Error running custom matcher", err); + this.logService.error("[PhishingDataService] Custom matcher failed", err); + return false; } - return false; } + return false; } diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index 815007e1d4c..2fa7bf8ec9e 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -1,14 +1,4 @@ -import { - concatMap, - distinctUntilChanged, - EMPTY, - filter, - map, - merge, - Subject, - switchMap, - tap, -} from "rxjs"; +import { distinctUntilChanged, EMPTY, filter, map, merge, Subject, switchMap, tap } from "rxjs"; import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -63,7 +53,7 @@ export class PhishingDetectionService { tap((message) => logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`), ), - concatMap(async (message) => { + switchMap(async (message) => { const url = new URL(message.url); this._ignoredHostnames.add(url.hostname); await BrowserApi.navigateTabToUrl(message.tabId, url); @@ -88,7 +78,9 @@ export class PhishingDetectionService { prev.ignored === curr.ignored, ), tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)), - concatMap(async ({ tabId, url, ignored }) => { + // Use switchMap to cancel any in-progress check when navigating to a new URL + // This prevents race conditions where a stale check redirects the user incorrectly + switchMap(async ({ tabId, url, ignored }) => { if (ignored) { // The next time this host is visited, block again this._ignoredHostnames.delete(url.hostname); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts index 99e101cc199..98835a5b366 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts @@ -435,6 +435,89 @@ describe("PhishingIndexedDbService", () => { }); }); + describe("findMatchingUrl", () => { + it("returns true when matcher finds a match", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + mockStore.set("https://phishing.net", { url: "https://phishing.net" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + + const matcher = (url: string) => url.includes("phishing"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readonly"); + expect(mockObjectStore.openCursor).toHaveBeenCalled(); + }); + + it("returns false when no URLs match", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + + const matcher = (url: string) => url.includes("notfound"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(false); + }); + + it("returns false when store is empty", async () => { + const matcher = (url: string) => url.includes("anything"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(false); + }); + + it("exits early on first match without iterating all records", async () => { + mockStore.set("https://match1.com", { url: "https://match1.com" }); + mockStore.set("https://match2.com", { url: "https://match2.com" }); + mockStore.set("https://match3.com", { url: "https://match3.com" }); + + const matcherCallCount = jest + .fn() + .mockImplementation((url: string) => url.includes("match2")); + await service.findMatchingUrl(matcherCallCount); + + // Matcher should be called for match1.com and match2.com, but NOT match3.com + // because it exits early on first match + expect(matcherCallCount).toHaveBeenCalledWith("https://match1.com"); + expect(matcherCallCount).toHaveBeenCalledWith("https://match2.com"); + expect(matcherCallCount).not.toHaveBeenCalledWith("https://match3.com"); + expect(matcherCallCount).toHaveBeenCalledTimes(2); + }); + + it("supports complex matcher logic", async () => { + mockStore.set("https://example.com/path", { url: "https://example.com/path" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + mockStore.set("https://phishing.net/login", { url: "https://phishing.net/login" }); + + const matcher = (url: string) => { + return url.includes("phishing") && url.includes("login"); + }; + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(true); + }); + + it("returns false on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const matcher = (url: string) => url.includes("test"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Cursor search failed", + expect.any(Error), + ); + }); + }); + describe("database initialization", () => { it("creates object store with keyPath on upgrade", async () => { mockDb.objectStoreNames.contains.mockReturnValue(false); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts index fe0f10da221..ea4b7987607 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts @@ -195,6 +195,60 @@ export class PhishingIndexedDbService { }); } + /** + * Checks if any URL in the database matches the given matcher function. + * Uses a cursor to iterate through records without loading all into memory. + * Returns immediately on first match for optimal performance. + * + * @param matcher - Function that tests each URL and returns true if it matches + * @returns `true` if any URL matches, `false` if none match or on error + */ + async findMatchingUrl(matcher: (url: string) => boolean): Promise { + this.logService.debug("[PhishingIndexedDbService] Searching for matching URL with cursor..."); + + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + return await this.cursorSearch(db, matcher); + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Cursor search failed", error); + return false; + } finally { + db?.close(); + } + } + + /** + * Performs cursor-based search through all URLs. + * Tests each URL with the matcher without accumulating records in memory. + */ + private cursorSearch(db: IDBDatabase, matcher: (url: string) => boolean): Promise { + return new Promise((resolve, reject) => { + const req = db + .transaction(this.STORE_NAME, "readonly") + .objectStore(this.STORE_NAME) + .openCursor(); + req.onerror = () => reject(req.error); + req.onsuccess = (e) => { + const cursor = (e.target as IDBRequest).result; + if (cursor) { + const url = (cursor.value as PhishingUrlRecord).url; + // Test the URL immediately without accumulating in memory + if (matcher(url)) { + // Found a match + resolve(true); + return; + } + // No match, continue to next record + cursor.continue(); + } else { + // Reached end of records without finding a match + resolve(false); + } + }; + }); + } + /** * Saves phishing URLs directly from a stream. * Processes data incrementally to minimize memory usage. diff --git a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts index c8be58b0bde..d7e755b34ea 100644 --- a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts +++ b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts @@ -35,7 +35,7 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { super(); // Always connect to the native messaging background if biometrics are enabled, not just when it is used // so that there is no wait when used. - const biometricsEnabled = this.biometricStateService.biometricUnlockEnabled$; + const biometricsEnabled = this.biometricStateService.biometricUnlockEnabled$(); combineLatest([timer(0, this.BACKGROUND_POLLING_INTERVAL), biometricsEnabled]) .pipe( diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts index ecdb899b9a7..934fb9307ee 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts @@ -375,7 +375,7 @@ describe("ExtensionLockComponentService", () => { platformUtilsService.supportsSecureStorage.mockReturnValue( mockInputs.platformSupportsSecureStorage, ); - biometricStateService.biometricUnlockEnabled$ = of(true); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true)); // PIN pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable); @@ -386,6 +386,7 @@ describe("ExtensionLockComponentService", () => { const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); expect(unlockOptions).toEqual(expectedOutput); + expect(biometricStateService.biometricUnlockEnabled$).toHaveBeenCalledWith(userId); }); }); }); diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts index 5e6e564bbc2..1ed9d1ea967 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts @@ -69,7 +69,7 @@ export class ExtensionLockComponentService implements LockComponentService { return combineLatest([ // Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to defer(async () => { - if (!(await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$))) { + if (!(await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$(userId)))) { return BiometricsStatus.NotEnabledLocally; } else { // TODO remove after 2025.3 diff --git a/apps/browser/src/platform/browser/browser-popup-utils.spec.ts b/apps/browser/src/platform/browser/browser-popup-utils.spec.ts index cb04f30b589..89459523843 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.spec.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.spec.ts @@ -140,11 +140,6 @@ describe("BrowserPopupUtils", () => { describe("openPopout", () => { beforeEach(() => { - jest.spyOn(BrowserApi, "getPlatformInfo").mockResolvedValueOnce({ - os: "linux", - arch: "x86-64", - nacl_arch: "x86-64", - }); jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({ id: 1, left: 100, @@ -155,8 +150,6 @@ describe("BrowserPopupUtils", () => { width: PopupWidthOptions.default, }); jest.spyOn(BrowserApi, "createWindow").mockImplementation(); - jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation(); - jest.spyOn(BrowserApi, "getPlatformInfo").mockImplementation(); }); it("creates a window with the default window options", async () => { @@ -274,63 +267,6 @@ describe("BrowserPopupUtils", () => { url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`, }); }); - - it("exits fullscreen and focuses popout window if the current window is fullscreen and platform is mac", async () => { - const url = "popup/index.html"; - jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false); - jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({ - os: "mac", - arch: "x86-64", - nacl_arch: "x86-64", - }); - jest.spyOn(BrowserApi, "getWindow").mockReset().mockResolvedValueOnce({ - id: 1, - left: 100, - top: 100, - focused: false, - alwaysOnTop: false, - incognito: false, - width: PopupWidthOptions.default, - state: "fullscreen", - }); - jest - .spyOn(BrowserApi, "createWindow") - .mockResolvedValueOnce({ id: 2 } as chrome.windows.Window); - - await BrowserPopupUtils.openPopout(url, { senderWindowId: 1 }); - expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(1, { - state: "maximized", - }); - expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(2, { - focused: true, - }); - }); - - it("doesnt exit fullscreen if the platform is not mac", async () => { - const url = "popup/index.html"; - jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false); - jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({ - os: "win", - arch: "x86-64", - nacl_arch: "x86-64", - }); - jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({ - id: 1, - left: 100, - top: 100, - focused: false, - alwaysOnTop: false, - incognito: false, - width: PopupWidthOptions.default, - state: "fullscreen", - }); - - await BrowserPopupUtils.openPopout(url); - - expect(BrowserApi.updateWindowProperties).not.toHaveBeenCalledWith(1, { - state: "maximized", - }); - }); }); describe("openCurrentPagePopout", () => { diff --git a/apps/browser/src/platform/browser/browser-popup-utils.ts b/apps/browser/src/platform/browser/browser-popup-utils.ts index c8dba57e708..7333023d178 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.ts @@ -168,29 +168,8 @@ export default class BrowserPopupUtils { ) { return; } - const platform = await BrowserApi.getPlatformInfo(); - const isMacOS = platform.os === "mac"; - const isFullscreen = senderWindow.state === "fullscreen"; - const isFullscreenAndMacOS = isFullscreen && isMacOS; - //macOS specific handling for improved UX when sender in fullscreen aka green button; - if (isFullscreenAndMacOS) { - await BrowserApi.updateWindowProperties(senderWindow.id, { - state: "maximized", - }); - //wait for macOS animation to finish - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - - const newWindow = await BrowserApi.createWindow(popoutWindowOptions); - - if (isFullscreenAndMacOS) { - await BrowserApi.updateWindowProperties(newWindow.id, { - focused: true, - }); - } - - return newWindow; + return await BrowserApi.createWindow(popoutWindowOptions); } /** diff --git a/apps/browser/src/platform/flags.ts b/apps/browser/src/platform/flags.ts index 2b1040bcd8a..30441d42979 100644 --- a/apps/browser/src/platform/flags.ts +++ b/apps/browser/src/platform/flags.ts @@ -8,12 +8,8 @@ import { import { GroupPolicyEnvironment } from "../admin-console/types/group-policy-environment"; -import { BrowserApi } from "./browser/browser-api"; - // required to avoid linting errors when there are no flags -export type Flags = { - accountSwitching?: boolean; -} & SharedFlags; +export type Flags = SharedFlags; // required to avoid linting errors when there are no flags export type DevFlags = { @@ -31,14 +27,3 @@ export function devFlagEnabled(flag: keyof DevFlags) { export function devFlagValue(flag: keyof DevFlags) { return baseDevFlagValue(flag); } - -/** Helper method to sync flag specifically for account switching, which as platform-based values. - * If this pattern needs to be repeated, it's better handled by increasing complexity of webpack configurations - * Not by expanding these flag getters. - */ -export function enableAccountSwitching(): boolean { - if (BrowserApi.isSafariApi) { - return false; - } - return flagEnabled("accountSwitching"); -} diff --git a/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts b/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts index e5fe95e2018..d53347b9dce 100644 --- a/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts +++ b/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts @@ -15,7 +15,7 @@ export class IpcContentScriptManagerService { } configService - .getFeatureFlag$(FeatureFlag.IpcChannelFramework) + .getFeatureFlag$(FeatureFlag.ContentScriptIpcChannelFramework) .pipe( mergeMap(async (enabled) => { if (!enabled) { diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html index bce2b5033ae..e04d302ea2c 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html @@ -18,11 +18,11 @@ type="button" role="link" > - + > {{ button.label | i18n }} diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts index 26138d57954..5a40b72daff 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts @@ -3,15 +3,15 @@ import { Component, Input } from "@angular/core"; import { RouterModule } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { IconModule, LinkModule } from "@bitwarden/components"; +import { SvgModule, LinkModule } from "@bitwarden/components"; export type NavButton = { label: string; page: string; - icon: Icon; - iconActive: Icon; + icon: BitSvg; + iconActive: BitSvg; showBerry?: boolean; }; @@ -20,7 +20,7 @@ export type NavButton = { @Component({ selector: "popup-tab-navigation", templateUrl: "popup-tab-navigation.component.html", - imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule], + imports: [CommonModule, LinkModule, RouterModule, JslibModule, SvgModule], host: { class: "tw-block tw-size-full tw-flex tw-flex-col", }, diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html index 484f9680519..2cf1998bb05 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -6,7 +6,7 @@ [pageTitle]="''" >
- +
diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts index 3a50f03e982..e07e9c50554 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts @@ -5,10 +5,10 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; import { Subject, filter, switchMap, takeUntil, tap } from "rxjs"; -import { BitwardenLogo, Icon } from "@bitwarden/assets/svg"; +import { BitwardenLogo, BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { - IconModule, + SvgModule, Translation, AnonLayoutComponent, AnonLayoutWrapperData, @@ -38,7 +38,7 @@ export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData { CommonModule, CurrentAccountComponent, I18nPipe, - IconModule, + SvgModule, PopOutComponent, PopupPageComponent, PopupHeaderComponent, @@ -54,7 +54,7 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { protected pageTitle: string; protected pageSubtitle: string; - protected pageIcon: Icon; + protected pageIcon: BitSvg; protected showReadonlyHostname: boolean; protected maxWidth: "md" | "3xl"; protected hasLoggedInAccount: boolean = false; diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html index 828c1667c57..94c1df46eea 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html @@ -14,7 +14,7 @@ class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-center tw-items-center tw-gap-2 tw-h-full tw-px-5" >
- +

{{ "createdSendSuccessfully" | i18n }} diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts index 521d72bba0c..a19897b6bbc 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts @@ -14,7 +14,7 @@ import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/defau import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; -import { ButtonModule, I18nMockService, IconModule, ToastService } from "@bitwarden/components"; +import { ButtonModule, I18nMockService, SvgModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; @@ -76,7 +76,7 @@ describe("SendCreatedComponent", () => { RouterTestingModule, JslibModule, ButtonModule, - IconModule, + SvgModule, PopOutComponent, PopupHeaderComponent, PopupPageComponent, diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts index e9109ec6c21..e3717075e24 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts @@ -13,7 +13,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; -import { ButtonModule, IconModule, ToastService } from "@bitwarden/components"; +import { ButtonModule, SvgModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; @@ -34,7 +34,7 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page PopupPageComponent, RouterModule, PopupFooterComponent, - IconModule, + SvgModule, ], }) export class SendCreatedComponent { 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 47ef0284d6a..0e9a12b504e 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 @@ -5,8 +5,7 @@ [showRefresh]="showRefresh" (onRefresh)="refreshCurrentTab()" [description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined" - showAutofillButton + isAutofillList [disableDescriptionMargin]="showEmptyAutofillTip$ | async" - [primaryActionAutofill]="clickItemsToAutofillVaultView$ | async" [groupByType]="groupByType()" > 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 64f662ab840..adb4e6b6866 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, startWith } from "rxjs"; +import { combineLatest, map, Observable } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; @@ -42,12 +42,6 @@ export class AutofillVaultListItemsComponent { */ protected showRefresh: boolean = BrowserPopupUtils.inSidebar(window); - /** Flag indicating whether the login item should automatically autofill when clicked */ - protected clickItemsToAutofillVaultView$: Observable = - this.vaultSettingsService.clickItemsToAutofillVaultView$.pipe( - startWith(true), // Start with true to avoid flashing the fill button on first load - ); - protected readonly groupByType = toSignal( this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)), ); diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html index 5f19092d6b0..1980e8aa356 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html @@ -2,7 +2,7 @@
- +

{{ "securityPrioritized" | i18n }}

{{ "securityPrioritizedBody" | i18n }}

@@ -11,7 +11,7 @@
- +

{{ "quickLogin" | i18n }}

{{ "quickLoginBody" | i18n }}

@@ -20,7 +20,7 @@
- +

{{ "secureUser" | i18n }}

{{ "secureUserBody" | i18n }}

@@ -29,7 +29,7 @@
- +

{{ "secureDevices" | i18n }}

{{ "secureDevicesBody" | i18n }}

diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts index 48c8f5682bc..5ad44c2f545 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts @@ -3,7 +3,7 @@ import { Router } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ItemTypes, LoginCards, NoCredentialsIcon, DevicesIcon } from "@bitwarden/assets/svg"; -import { ButtonModule, DialogModule, IconModule, TypographyModule } from "@bitwarden/components"; +import { ButtonModule, DialogModule, SvgModule, TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { VaultCarouselModule } from "@bitwarden/vault"; @@ -17,7 +17,7 @@ import { IntroCarouselService } from "../../../services/intro-carousel.service"; imports: [ VaultCarouselModule, ButtonModule, - IconModule, + SvgModule, DialogModule, TypographyModule, JslibModule, diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index be67869d3df..223029aed0a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -8,14 +8,14 @@ > @if (!decryptionFailure) { - + - + diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index d7de51ad20f..7a6c1db8026 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { booleanAttribute, Component, Input } from "@angular/core"; +import { booleanAttribute, Component, input, Input } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; import { filter } from "rxjs/operators"; @@ -76,22 +76,10 @@ export class ItemMoreOptionsComponent { } /** - * Flag to show view item menu option. Used when something else is - * assigned as the primary action for the item, such as autofill. - */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ transform: booleanAttribute }) - showViewOption = false; - - /** - * Flag to hide the autofill menu options. Used for items that are + * Flag to show the autofill menu options. Used for items that are * already in the autofill list suggestion. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ transform: booleanAttribute }) - hideAutofillOptions = false; + readonly showAutofill = input(false, { transform: booleanAttribute }); protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; 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 3dac158b8e1..d3bc025905e 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 @@ -90,11 +90,11 @@ - + + - + - - - + @if (!cipher.isDeleted && action === "view") { + + } + + @if (action === "edit" || action === "clone" || action === "add") { + + } + + @if (cipher.isDeleted && cipher.permissions.restore) { + + } + @if (showCloneOption) { } -
- - - -
+ @if (hasFooterAction) { +
+ @if (showArchiveButton) { + + } + + @if (showUnarchiveButton) { + + } + + +
+ }
diff --git a/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts b/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts index e9cf87a114d..e174d01a75d 100644 --- a/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts +++ b/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts @@ -2,7 +2,6 @@ import { TestBed } from "@angular/core/testing"; import { ReplaySubject } from "rxjs"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Environment, EnvironmentService, @@ -46,23 +45,16 @@ describe("PeopleTableDataSource", () => { isCloud: () => false, } as Environment); - const mockConfigService = { - getFeatureFlag$: jest.fn(() => featureFlagSubject.asObservable()), - } as any; - const mockEnvironmentService = { environment$: environmentSubject.asObservable(), } as any; TestBed.configureTestingModule({ - providers: [ - { provide: ConfigService, useValue: mockConfigService }, - { provide: EnvironmentService, useValue: mockEnvironmentService }, - ], + providers: [{ provide: EnvironmentService, useValue: mockEnvironmentService }], }); dataSource = TestBed.runInInjectionContext( - () => new TestPeopleTableDataSource(mockConfigService, mockEnvironmentService), + () => new TestPeopleTableDataSource(mockEnvironmentService), ); }); diff --git a/apps/web/src/app/admin-console/common/people-table-data-source.ts b/apps/web/src/app/admin-console/common/people-table-data-source.ts index d39a4f29653..a3ffbaeb7b5 100644 --- a/apps/web/src/app/admin-console/common/people-table-data-source.ts +++ b/apps/web/src/app/admin-console/common/people-table-data-source.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { computed, Signal } from "@angular/core"; +import { Signal } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { Observable, Subject, map } from "rxjs"; @@ -9,8 +9,6 @@ import { ProviderUserStatusType, } from "@bitwarden/common/admin-console/enums"; import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { TableDataSource } from "@bitwarden/components"; @@ -27,8 +25,7 @@ export type ProviderUser = ProviderUserUserDetailsResponse; export const MaxCheckedCount = 500; /** - * Maximum for bulk reinvite operations when the IncreaseBulkReinviteLimitForCloud - * feature flag is enabled on cloud environments. + * Maximum for bulk reinvite limit in cloud environments. */ export const CloudBulkReinviteLimit = 8000; @@ -78,18 +75,15 @@ export abstract class PeopleTableDataSource extends Tab confirmedUserCount: number; revokedUserCount: number; - /** True when increased bulk limit feature is enabled (feature flag + cloud environment) */ + /** True when increased bulk limit feature is enabled (cloud environment) */ readonly isIncreasedBulkLimitEnabled: Signal; - constructor(configService: ConfigService, environmentService: EnvironmentService) { + constructor(environmentService: EnvironmentService) { super(); - const featureFlagEnabled = toSignal( - configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + this.isIncreasedBulkLimitEnabled = toSignal( + environmentService.environment$.pipe(map((env) => env.isCloud())), ); - const isCloud = toSignal(environmentService.environment$.pipe(map((env) => env.isCloud()))); - - this.isIncreasedBulkLimitEnabled = computed(() => featureFlagEnabled() && isCloud()); } override set data(data: T[]) { @@ -224,12 +218,9 @@ export abstract class PeopleTableDataSource extends Tab } /** - * Gets checked users with optional limiting based on the IncreaseBulkReinviteLimitForCloud feature flag. + * Returns checked users in visible order, optionally limited to the specified count. * - * When the feature flag is enabled: Returns checked users in visible order, limited to the specified count. - * When the feature flag is disabled: Returns all checked users without applying any limit. - * - * @param limit The maximum number of users to return (only applied when feature flag is enabled) + * @param limit The maximum number of users to return * @returns The checked users array */ getCheckedUsersWithLimit(limit: number): T[] { diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index b00e4d9840d..2d1fde10856 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -26,7 +26,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getById } from "@bitwarden/common/platform/misc"; -import { BannerModule, IconModule } from "@bitwarden/components"; +import { BannerModule, SvgModule } from "@bitwarden/components"; import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; @@ -47,7 +47,7 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module"; RouterModule, JslibModule, WebLayoutModule, - IconModule, + SvgModule, OrgSwitcherComponent, BannerModule, TaxIdWarningComponent, diff --git a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts index 99fd81aa48d..93960820fbb 100644 --- a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts @@ -33,7 +33,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -124,7 +123,6 @@ export class MembersComponent extends BaseMembersComponent private policyApiService: PolicyApiServiceAbstraction, private organizationMetadataService: OrganizationMetadataServiceAbstraction, private memberExportService: MemberExportService, - private configService: ConfigService, private environmentService: EnvironmentService, ) { super( @@ -139,7 +137,7 @@ export class MembersComponent extends BaseMembersComponent toastService, ); - this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + this.dataSource = new MembersTableDataSource(this.environmentService); const organization$ = this.route.params.pipe( concatMap((params) => diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 9d367657d55..e3ed575d81b 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -33,7 +33,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -100,7 +99,6 @@ export class vNextMembersComponent { private policyService = inject(PolicyService); private policyApiService = inject(PolicyApiServiceAbstraction); private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); - private configService = inject(ConfigService); private environmentService = inject(EnvironmentService); private memberExportService = inject(MemberExportService); @@ -114,7 +112,7 @@ export class vNextMembersComponent { protected statusToggle = new BehaviorSubject(undefined); protected readonly dataSource: Signal = signal( - new MembersTableDataSource(this.configService, this.environmentService), + new MembersTableDataSource(this.environmentService), ); protected readonly organization: Signal; protected readonly firstLoaded: WritableSignal = signal(false); @@ -389,7 +387,7 @@ export class vNextMembersComponent { // Capture the original count BEFORE enforcing the limit const originalInvitedCount = allInvitedUsers.length; - // When feature flag is enabled, limit invited users and uncheck the excess + // In cloud environments, limit invited users and uncheck the excess let filteredUsers: OrganizationUserView[]; if (this.dataSource().isIncreasedBulkLimitEnabled()) { filteredUsers = this.dataSource().limitAndUncheckExcess( @@ -418,7 +416,7 @@ export class vNextMembersComponent { this.validationService.showError(result.failed); } - // When feature flag is enabled, show toast instead of dialog + // In cloud environments, show toast instead of dialog if (this.dataSource().isIncreasedBulkLimitEnabled()) { const selectedCount = originalInvitedCount; const invitedCount = filteredUsers.length; @@ -441,7 +439,7 @@ export class vNextMembersComponent { }); } } else { - // Feature flag disabled - show legacy dialog + // In self-hosted environments, show legacy dialog await this.memberDialogManager.openBulkStatusDialog( users, filteredUsers, @@ -514,7 +512,7 @@ export class vNextMembersComponent { if (result.error != null) { this.toastService.showToast({ variant: "error", - message: this.i18nService.t(result.error), + message: result.error, }); this.logService.error(result.error); return; diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index 1df285d7ba2..423977e73c4 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -17,7 +17,6 @@ import { import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; @@ -32,7 +31,6 @@ describe("MemberActionsService", () => { let service: MemberActionsService; let organizationUserApiService: MockProxy; let organizationUserService: MockProxy; - let configService: MockProxy; let organizationMetadataService: MockProxy; const organizationId = newGuid() as OrganizationId; @@ -44,7 +42,6 @@ describe("MemberActionsService", () => { beforeEach(() => { organizationUserApiService = mock(); organizationUserService = mock(); - configService = mock(); organizationMetadataService = mock(); mockOrganization = { @@ -68,7 +65,6 @@ describe("MemberActionsService", () => { MemberActionsService, { provide: OrganizationUserApiService, useValue: organizationUserApiService }, { provide: OrganizationUserService, useValue: organizationUserService }, - { provide: ConfigService, useValue: configService }, { provide: OrganizationMetadataServiceAbstraction, useValue: organizationMetadataService, @@ -279,308 +275,247 @@ describe("MemberActionsService", () => { }); describe("bulkReinvite", () => { - const userIds = [newGuid() as UserId, newGuid() as UserId, newGuid() as UserId]; + it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => { + const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId); + const mockResponse = new ListResponse( + { + data: userIdsBatch.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - describe("when feature flag is false", () => { - beforeEach(() => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - }); + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - it("should successfully reinvite multiple users", async () => { - const mockResponse = new ListResponse( - { - data: userIds.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - - const result = await service.bulkReinvite(mockOrganization, userIds); - - expect(result.failed).toEqual([]); - expect(result.successful).toBeDefined(); - expect(result.successful).toEqual(mockResponse); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( - organizationId, - userIds, - ); - }); - - it("should handle bulk reinvite errors", async () => { - const errorMessage = "Bulk reinvite failed"; - organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( - new Error(errorMessage), - ); - - const result = await service.bulkReinvite(mockOrganization, userIds); - - expect(result.successful).toBeUndefined(); - expect(result.failed).toHaveLength(3); - expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage }); - }); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.failed).toHaveLength(0); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(1); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( + organizationId, + userIdsBatch, + ); }); - describe("when feature flag is true (batching behavior)", () => { - beforeEach(() => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - }); - it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => { - const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId); - const mockResponse = new ListResponse( - { - data: userIdsBatch.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); - expect(result.failed).toHaveLength(0); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 1, - ); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( - organizationId, - userIdsBatch, - ); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => { - const totalUsers = REQUESTS_PER_BATCH + 100; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.failed).toHaveLength(0); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( + 1, + organizationId, + userIdsBatch.slice(0, REQUESTS_PER_BATCH), + ); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( + 2, + organizationId, + userIdsBatch.slice(REQUESTS_PER_BATCH), + ); + }); - const mockResponse2 = new ListResponse( - { - data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should aggregate results across multiple successful batches", async () => { + const totalUsers = REQUESTS_PER_BATCH + 50; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(totalUsers); - expect(result.failed).toHaveLength(0); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 2, - ); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( - 1, - organizationId, - userIdsBatch.slice(0, REQUESTS_PER_BATCH), - ); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( - 2, - organizationId, - userIdsBatch.slice(REQUESTS_PER_BATCH), - ); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should aggregate results across multiple successful batches", async () => { - const totalUsers = REQUESTS_PER_BATCH + 50; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data); + expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); + expect(result.failed).toHaveLength(0); + }); - const mockResponse2 = new ListResponse( - { - data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should handle mixed individual errors across multiple batches", async () => { + const totalUsers = REQUESTS_PER_BATCH + 4; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({ + id, + error: index % 10 === 0 ? "Rate limit exceeded" : null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: [ + { id: userIdsBatch[REQUESTS_PER_BATCH], error: null }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" }, + ], + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(totalUsers); - expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual( - mockResponse1.data, - ); - expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); - expect(result.failed).toHaveLength(0); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should handle mixed individual errors across multiple batches", async () => { - const totalUsers = REQUESTS_PER_BATCH + 4; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({ - id, - error: index % 10 === 0 ? "Rate limit exceeded" : null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + // Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch + // Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values + const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1; + const expectedFailuresInBatch2 = 2; + const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2; + const expectedSuccesses = totalUsers - expectedTotalFailures; - const mockResponse2 = new ListResponse( - { - data: [ - { id: userIdsBatch[REQUESTS_PER_BATCH], error: null }, - { id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" }, - { id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null }, - { id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" }, - ], - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(expectedSuccesses); + expect(result.failed).toHaveLength(expectedTotalFailures); + expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true); + expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true); + expect(result.failed.some((f) => f.error === "User suspended")).toBe(true); + }); - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + it("should aggregate all failures when all batches fail", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const errorMessage = "All batches failed"; - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( + new Error(errorMessage), + ); - // Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch - // Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values - const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1; - const expectedFailuresInBatch2 = 2; - const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2; - const expectedSuccesses = totalUsers - expectedTotalFailures; + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(expectedSuccesses); - expect(result.failed).toHaveLength(expectedTotalFailures); - expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true); - expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true); - expect(result.failed.some((f) => f.error === "User suspended")).toBe(true); - }); + expect(result.successful).toBeUndefined(); + expect(result.failed).toHaveLength(totalUsers); + expect(result.failed.every((f) => f.error === errorMessage)).toBe(true); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); + }); - it("should aggregate all failures when all batches fail", async () => { - const totalUsers = REQUESTS_PER_BATCH + 100; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - const errorMessage = "All batches failed"; + it("should handle empty data in batch response", async () => { + const totalUsers = REQUESTS_PER_BATCH + 50; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( - new Error(errorMessage), - ); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: [], + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeUndefined(); - expect(result.failed).toHaveLength(totalUsers); - expect(result.failed.every((f) => f.error === errorMessage)).toBe(true); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 2, - ); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should handle empty data in batch response", async () => { - const totalUsers = REQUESTS_PER_BATCH + 50; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.failed).toHaveLength(0); + }); - const mockResponse2 = new ListResponse( - { - data: [], - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should process batches sequentially in order", async () => { + const totalUsers = REQUESTS_PER_BATCH * 2; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const callOrder: number[] = []; - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation( + async (orgId, ids) => { + const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2; + callOrder.push(batchIndex); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + return new ListResponse( + { + data: ids.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + }, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); - expect(result.failed).toHaveLength(0); - }); + await service.bulkReinvite(mockOrganization, userIdsBatch); - it("should process batches sequentially in order", async () => { - const totalUsers = REQUESTS_PER_BATCH * 2; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - const callOrder: number[] = []; - - organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation( - async (orgId, ids) => { - const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2; - callOrder.push(batchIndex); - - return new ListResponse( - { - data: ids.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); - }, - ); - - await service.bulkReinvite(mockOrganization, userIdsBatch); - - expect(callOrder).toEqual([1, 2]); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 2, - ); - }); + expect(callOrder).toEqual([1, 2]); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index 5833238209c..e8c4a21d675 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -16,9 +16,7 @@ import { import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { DialogService } from "@bitwarden/components"; @@ -45,7 +43,6 @@ export interface BulkActionResult { export class MemberActionsService { private organizationUserApiService = inject(OrganizationUserApiService); private organizationUserService = inject(OrganizationUserService); - private configService = inject(ConfigService); private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); private apiService = inject(ApiService); private dialogService = inject(DialogService); @@ -175,18 +172,9 @@ export class MemberActionsService { async bulkReinvite(organization: Organization, userIds: UserId[]): Promise { this.startProcessing(); try { - const increaseBulkReinviteLimitForCloud = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) => + this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch), ); - if (increaseBulkReinviteLimitForCloud) { - return await this.vNextBulkReinvite(organization, userIds); - } else { - const result = await this.organizationUserApiService.postManyOrganizationUserReinvite( - organization.id, - userIds, - ); - return { successful: result, failed: [] }; - } } catch (error) { return { failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), @@ -196,15 +184,6 @@ export class MemberActionsService { } } - async vNextBulkReinvite( - organization: Organization, - userIds: UserId[], - ): Promise { - return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) => - this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch), - ); - } - allowResetPassword( orgUser: OrganizationUserView, organization: Organization, diff --git a/apps/web/src/app/admin-console/organizations/policies/index.ts b/apps/web/src/app/admin-console/organizations/policies/index.ts index eb614e180e1..8e730d3a6b8 100644 --- a/apps/web/src/app/admin-console/organizations/policies/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/index.ts @@ -5,3 +5,4 @@ export { POLICY_EDIT_REGISTER } from "./policy-register-token"; export { AutoConfirmPolicy } from "./policy-edit-definitions"; export { PolicyEditDialogResult } from "./policy-edit-dialog.component"; export * from "./policy-edit-dialogs"; +export { PolicyOrderPipe } from "./pipes/policy-order.pipe"; diff --git a/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts b/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts new file mode 100644 index 00000000000..02092f05b92 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts @@ -0,0 +1,66 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +import { BasePolicyEditDefinition } from "../base-policy-edit.component"; + +/** + * Order mapping for policies. Policies are ordered according to this mapping. + * Policies not in this mapping will appear at the end, maintaining their relative order. + */ +const POLICY_ORDER_MAP = new Map([ + ["singleOrg", 1], + ["organizationDataOwnership", 2], + ["centralizeDataOwnership", 2], + ["masterPassPolicyTitle", 3], + ["accountRecoveryPolicy", 4], + ["requireSso", 5], + ["automaticAppLoginWithSSO", 6], + ["twoStepLoginPolicyTitle", 7], + ["blockClaimedDomainAccountCreation", 8], + ["sessionTimeoutPolicyTitle", 9], + ["removeUnlockWithPinPolicyTitle", 10], + ["passwordGenerator", 11], + ["uriMatchDetectionPolicy", 12], + ["activateAutofillPolicy", 13], + ["sendOptions", 14], + ["disableSend", 15], + ["restrictedItemTypePolicy", 16], + ["freeFamiliesSponsorship", 17], + ["disableExport", 18], +]); + +/** + * Default order for policies not in the mapping. This ensures unmapped policies + * appear at the end while maintaining their relative order. + */ +const DEFAULT_ORDER = 999; + +@Pipe({ + name: "policyOrder", + standalone: true, +}) +export class PolicyOrderPipe implements PipeTransform { + transform( + policies: readonly BasePolicyEditDefinition[] | null | undefined, + ): BasePolicyEditDefinition[] { + if (policies == null || policies.length === 0) { + return []; + } + + const sortedPolicies = [...policies]; + + sortedPolicies.sort((a, b) => { + const orderA = POLICY_ORDER_MAP.get(a.name) ?? DEFAULT_ORDER; + const orderB = POLICY_ORDER_MAP.get(b.name) ?? DEFAULT_ORDER; + + if (orderA !== orderB) { + return orderA - orderB; + } + + const indexA = policies.indexOf(a); + const indexB = policies.indexOf(b); + return indexA - indexB; + }); + + return sortedPolicies; + } +} diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.html b/apps/web/src/app/admin-console/organizations/policies/policies.component.html index c38092146ab..902c7e79d55 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.html @@ -15,7 +15,7 @@ } @else { - @for (p of policies$ | async; track $index) { + @for (p of policies$ | async | policyOrder; track $index) { @if (p.display$(organization, configService) | async) { diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index 1f9a8deaa85..d13a2097628 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -21,13 +21,14 @@ import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component"; +import { PolicyOrderPipe } from "./pipes/policy-order.pipe"; import { PolicyEditDialogComponent } from "./policy-edit-dialog.component"; import { PolicyListService } from "./policy-list.service"; import { POLICY_EDIT_REGISTER } from "./policy-register-token"; @Component({ templateUrl: "policies.component.html", - imports: [SharedModule, HeaderModule], + imports: [SharedModule, HeaderModule, PolicyOrderPipe], providers: [ safeProvider({ provide: PolicyListService, diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html index 54f166b662e..a8e3236dad8 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html @@ -44,7 +44,7 @@
- +
  1. 1. {{ "autoConfirmExtension1" | i18n }}
  2. diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts new file mode 100644 index 00000000000..09b2f8961f3 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts @@ -0,0 +1,270 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder } from "@angular/forms"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components"; +import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; + +import { + AutoConfirmPolicyDialogComponent, + AutoConfirmPolicyDialogData, +} from "./auto-confirm-edit-policy-dialog.component"; + +describe("AutoConfirmPolicyDialogComponent", () => { + let component: AutoConfirmPolicyDialogComponent; + let fixture: ComponentFixture; + + let mockPolicyApiService: MockProxy; + let mockAccountService: FakeAccountService; + let mockOrganizationService: MockProxy; + let mockPolicyService: MockProxy; + let mockRouter: MockProxy; + let mockAutoConfirmService: MockProxy; + let mockDialogRef: MockProxy; + let mockToastService: MockProxy; + let mockI18nService: MockProxy; + let mockKeyService: MockProxy; + + const mockUserId = newGuid() as UserId; + const mockOrgId = newGuid() as OrganizationId; + + const mockDialogData: AutoConfirmPolicyDialogData = { + organizationId: mockOrgId, + policy: { + name: "autoConfirm", + description: "Auto Confirm Policy", + type: PolicyType.AutoConfirm, + component: {} as any, + showDescription: true, + display$: () => of(true), + }, + firstTimeDialog: false, + }; + + const mockOrg = { + id: mockOrgId, + name: "Test Organization", + enabled: true, + isAdmin: true, + canManagePolicies: true, + } as Organization; + + beforeEach(async () => { + mockPolicyApiService = mock(); + mockAccountService = mockAccountServiceWith(mockUserId); + mockOrganizationService = mock(); + mockPolicyService = mock(); + mockRouter = mock(); + mockAutoConfirmService = mock(); + mockDialogRef = mock(); + mockToastService = mock(); + mockI18nService = mock(); + mockKeyService = mock(); + + mockPolicyService.policies$.mockReturnValue(of([])); + mockOrganizationService.organizations$.mockReturnValue(of([mockOrg])); + + await TestBed.configureTestingModule({ + imports: [AutoConfirmPolicyDialogComponent], + providers: [ + FormBuilder, + { provide: DIALOG_DATA, useValue: mockDialogData }, + { provide: AccountService, useValue: mockAccountService }, + { provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService }, + { provide: I18nService, useValue: mockI18nService }, + { provide: DialogRef, useValue: mockDialogRef }, + { provide: ToastService, useValue: mockToastService }, + { provide: KeyService, useValue: mockKeyService }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: PolicyService, useValue: mockPolicyService }, + { provide: Router, useValue: mockRouter }, + { provide: AutomaticUserConfirmationService, useValue: mockAutoConfirmService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(AutoConfirmPolicyDialogComponent, { + set: { template: "
    " }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(AutoConfirmPolicyDialogComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("handleSubmit", () => { + beforeEach(() => { + // Mock the policyComponent + component.policyComponent = { + buildRequest: jest.fn().mockResolvedValue({ enabled: true, data: null }), + enabled: { value: true }, + setSingleOrgEnabled: jest.fn(), + } as any; + + mockAutoConfirmService.configuration$.mockReturnValue( + of({ enabled: false, showSetupDialog: true, showBrowserNotification: undefined }), + ); + mockAutoConfirmService.upsert.mockResolvedValue(undefined); + mockI18nService.t.mockReturnValue("Policy updated"); + }); + + it("should enable SingleOrg policy when it was not already enabled", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + // Call handleSubmit with singleOrgEnabled = false (meaning it needs to be enabled) + await component["handleSubmit"](false); + + // First call should be SingleOrg enable + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 1, + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should not enable SingleOrg policy when it was already enabled", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + // Call handleSubmit with singleOrgEnabled = true (meaning it's already enabled) + await component["handleSubmit"](true); + + // Should only call putPolicyVNext once (for AutoConfirm, not SingleOrg) + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith( + mockOrgId, + PolicyType.AutoConfirm, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should rollback SingleOrg policy when AutoConfirm fails and SingleOrg was enabled during action", async () => { + const autoConfirmError = new Error("AutoConfirm failed"); + + // First call (SingleOrg enable) succeeds, second call (AutoConfirm) fails, third call (SingleOrg rollback) succeeds + mockPolicyApiService.putPolicyVNext + .mockResolvedValueOnce({} as any) // SingleOrg enable + .mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails + .mockResolvedValueOnce({} as any); // SingleOrg rollback + + await expect(component["handleSubmit"](false)).rejects.toThrow("AutoConfirm failed"); + + // Verify: SingleOrg enabled, AutoConfirm attempted, SingleOrg rolled back + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(3); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 1, + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: true, data: null } }, + ); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 2, + mockOrgId, + PolicyType.AutoConfirm, + { policy: { enabled: true, data: null } }, + ); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 3, + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: false, data: null } }, + ); + }); + + it("should not rollback SingleOrg policy when AutoConfirm fails but SingleOrg was already enabled", async () => { + const autoConfirmError = new Error("AutoConfirm failed"); + + // AutoConfirm call fails (SingleOrg was already enabled, so no SingleOrg calls) + mockPolicyApiService.putPolicyVNext.mockRejectedValue(autoConfirmError); + + await expect(component["handleSubmit"](true)).rejects.toThrow("AutoConfirm failed"); + + // Verify only AutoConfirm was called (no SingleOrg enable/rollback) + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith( + mockOrgId, + PolicyType.AutoConfirm, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should keep both policies enabled when both submissions succeed", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + await component["handleSubmit"](false); + + // Verify two calls: SingleOrg enable and AutoConfirm enable + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(2); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 1, + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: true, data: null } }, + ); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 2, + mockOrgId, + PolicyType.AutoConfirm, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should re-throw the error after rollback", async () => { + const autoConfirmError = new Error("Network error"); + + mockPolicyApiService.putPolicyVNext + .mockResolvedValueOnce({} as any) // SingleOrg enable + .mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails + .mockResolvedValueOnce({} as any); // SingleOrg rollback + + await expect(component["handleSubmit"](false)).rejects.toThrow("Network error"); + }); + }); + + describe("setSingleOrgPolicy", () => { + it("should call putPolicyVNext with enabled: true when enabling", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + await component["setSingleOrgPolicy"](true); + + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith( + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should call putPolicyVNext with enabled: false when disabling", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + await component["setSingleOrgPolicy"](false); + + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith( + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: false, data: null } }, + ); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts index fbdeffc71bb..f0146225b8d 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts @@ -181,10 +181,21 @@ export class AutoConfirmPolicyDialogComponent } private async handleSubmit(singleOrgEnabled: boolean) { - if (!singleOrgEnabled) { - await this.submitSingleOrg(); + const enabledSingleOrgDuringAction = !singleOrgEnabled; + + if (enabledSingleOrgDuringAction) { + await this.setSingleOrgPolicy(true); + } + + try { + await this.submitAutoConfirm(); + } catch (error) { + // Roll back SingleOrg if we enabled it during this action + if (enabledSingleOrgDuringAction) { + await this.setSingleOrgPolicy(false); + } + throw error; } - await this.submitAutoConfirm(); } /** @@ -198,11 +209,9 @@ export class AutoConfirmPolicyDialogComponent const autoConfirmRequest = await this.policyComponent.buildRequest(); - await this.policyApiService.putPolicy( - this.data.organizationId, - this.data.policy.type, - autoConfirmRequest, - ); + await this.policyApiService.putPolicyVNext(this.data.organizationId, this.data.policy.type, { + policy: autoConfirmRequest, + }); const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); @@ -225,17 +234,15 @@ export class AutoConfirmPolicyDialogComponent } } - private async submitSingleOrg(): Promise { + private async setSingleOrgPolicy(enabled: boolean): Promise { const singleOrgRequest: PolicyRequest = { - enabled: true, + enabled, data: null, }; - await this.policyApiService.putPolicyVNext( - this.data.organizationId, - PolicyType.SingleOrg, - singleOrgRequest, - ); + await this.policyApiService.putPolicyVNext(this.data.organizationId, PolicyType.SingleOrg, { + policy: singleOrgRequest, + }); } private async openBrowserExtension() { diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html index e509692aba7..431d7711331 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html @@ -63,7 +63,7 @@ - +
    {{ "readOnlyCollectionAccess" | i18n }} diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts index 4f40ea701d2..2f9ddddd8cb 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -361,6 +361,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { return this.params.readonly === true; } + protected get accessTabLabel(): string { + return this.dialogReadonly + ? this.i18nService.t("viewAccess") + : this.i18nService.t("editAccess"); + } + protected async cancel() { this.close(CollectionDialogAction.Canceled); } diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html index ca1264829b9..0255e1a6a99 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html +++ b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html @@ -1,7 +1,7 @@
    - - + +
    - +

    {{ "creatingPasskeyLoading" | i18n }}

    {{ "creatingPasskeyLoadingInfo" | i18n }}

    @@ -27,7 +27,7 @@ class="tw-flex tw-flex-col tw-items-center" >
    - +

    {{ "errorCreatingPasskey" | i18n }}

    {{ "errorCreatingPasskeyInfo" | i18n }}

    diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index f060d29b377..2d0a4b465ed 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -57,12 +57,8 @@
    - - {{ - (sub.subscription.periodEndDate | date: "MMM d, y") + - ", " + - (discountedSubscriptionAmount | currency: "$") - }} + + {{ sub.subscription.periodEndDate | date: "MMM d, y" }}
    - - {{ - (sub.subscription.periodEndDate | date: "MMM d, y") + - ", " + - (subscriptionAmount | currency: "$") - }} + + {{ sub.subscription.periodEndDate | date: "MMM d, y" }}
    diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 4858deabec6..496ddb4ff9b 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -242,7 +242,7 @@
    - +

    {{ "billingManagedByProvider" | i18n: userOrg.providerName }}

    {{ "billingContactProviderForAssistance" | i18n }}

    diff --git a/apps/web/src/app/billing/organizations/subscription-hidden.component.ts b/apps/web/src/app/billing/organizations/subscription-hidden.component.ts index ef6e2dd0495..249cf999305 100644 --- a/apps/web/src/app/billing/organizations/subscription-hidden.component.ts +++ b/apps/web/src/app/billing/organizations/subscription-hidden.component.ts @@ -10,7 +10,7 @@ import { GearIcon } from "@bitwarden/assets/svg"; selector: "app-org-subscription-hidden", template: `
    - +

    {{ "billingManagedByProvider" | i18n: providerName }}

    {{ "billingContactProviderForAssistance" | i18n }}

    diff --git a/apps/web/src/app/billing/shared/sm-subscribe.component.html b/apps/web/src/app/billing/shared/sm-subscribe.component.html index 6cdaeb9476d..70990d2ee4c 100644 --- a/apps/web/src/app/billing/shared/sm-subscribe.component.html +++ b/apps/web/src/app/billing/shared/sm-subscribe.component.html @@ -2,7 +2,7 @@

    {{ "moreFromBitwarden" | i18n }}

    - +
    -
    -

    {{ "reportError" | i18n }}...

    - - - {{ "breachUsernameNotFound" | i18n: checkedUsername }} - - - {{ "breachUsernameFound" | i18n: checkedUsername : breachedAccounts.length }} - -
      -
    • -
      - -
      -
      -

      {{ a.title }}

      -

      -

      {{ "compromisedData" | i18n }}:

      -
        -
      • {{ d }}
      • -
      -
      -
      -
      -
      {{ "website" | i18n }}
      -
      {{ a.domain }}
      -
      {{ "affectedUsers" | i18n }}
      -
      {{ a.pwnCount | number }}
      -
      {{ "breachOccurred" | i18n }}
      -
      {{ a.breachDate | date: "mediumDate" }}
      -
      {{ "breachReported" | i18n }}
      -
      {{ a.addedDate | date: "mediumDate" }}
      -
      -
      -
    • -
    -
    -
    + @if (!loading && checkedUsername) { +
    + @if (error) { +

    {{ "reportError" | i18n }}...

    + } @else { + @if (!breachedAccounts.length) { + + {{ "breachUsernameNotFound" | i18n: checkedUsername }} + + } @else { + + {{ "breachUsernameFound" | i18n: checkedUsername : breachedAccounts.length }} + +
      + @for (a of breachedAccounts; track a) { +
    • +
      + +
      +
      +

      {{ a.title }}

      +

      +

      {{ "compromisedData" | i18n }}:

      +
        + @for (d of a.dataClasses; track d) { +
      • {{ d }}
      • + } +
      +
      +
      +
      +
      {{ "website" | i18n }}
      +
      {{ a.domain }}
      +
      {{ "affectedUsers" | i18n }}
      +
      {{ a.pwnCount | number }}
      +
      {{ "breachOccurred" | i18n }}
      +
      {{ a.breachDate | date: "mediumDate" }}
      +
      {{ "breachReported" | i18n }}
      +
      {{ a.addedDate | date: "mediumDate" }}
      +
      +
      +
    • + } +
    + } + } +
    + } diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html index 55e6678bd58..ba118ea6663 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html @@ -5,108 +5,119 @@ -
    - - {{ "noExposedPasswords" | i18n }} - - - - {{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - @if (showFilterToggle && !isAdminConsoleActive) { - @if (canDisplayToggleGroup()) { - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - } @else { - - } - } - - - - - {{ "name" | i18n }} - - {{ "owner" | i18n }} - - - {{ "timesExposed" | i18n }} - - - - - - - - - - {{ row.name }} - - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ row.subTitle }} - - - + @if (!ciphers.length) { + + {{ "noExposedPasswords" | i18n }} + + } @else { + + {{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - {{ "exposedXTimes" | i18n: (row.exposedXTimes | number) }} - - -
    -
    -
    -
    + @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + + + {{ "name" | i18n }} + @if (!isAdminConsoleActive) { + + {{ "owner" | i18n }} + + } + + {{ "timesExposed" | i18n }} + + + + + + + + @if (!organization || canManageCipher(row)) { + + {{ row.name }} + + } @else { + {{ row.name }} + } + @if (!organization && row.organizationId) { + + {{ "shared" | i18n }} + } + @if (row.hasAttachments) { + + {{ "attachments" | i18n }} + } +
    + {{ row.subTitle }} + + @if (!isAdminConsoleActive) { + + @if (!organization) { + + + } + + } + + + {{ "exposedXTimes" | i18n: (row.exposedXTimes | number) }} + + +
    +
    + } +
    + } diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html index a1d3f2a38be..4999d572969 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html @@ -2,117 +2,124 @@

    {{ "inactive2faReportDesc" | i18n }}

    -
    - - {{ "loading" | i18n }} -
    -
    - - {{ "noInactive2fa" | i18n }} - - - - {{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - @if (showFilterToggle && !isAdminConsoleActive) { - @if (canDisplayToggleGroup()) { - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - } @else { - + @if (!hasLoaded && loading) { +
    + + {{ "loading" | i18n }} +
    + } @else { +
    + @if (!ciphers.length) { + + {{ "noInactive2fa" | i18n }} + + } @else { + + {{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + + @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } } + + @if (!isAdminConsoleActive) { + + + {{ "name" | i18n }} + {{ "owner" | i18n }} + + + } + + + + + + @if (!organization || canManageCipher(row)) { + + {{ row.name }} + + } @else { + + {{ row.name }} + + } + @if (!organization && row.organizationId) { + + + {{ "shared" | i18n }} + + } + @if (row.hasAttachments) { + + + {{ "attachments" | i18n }} + + } +
    + {{ row.subTitle }} + + + @if (!organization) { + + } + + + @if (cipherDocs.has(row.id)) { + + {{ "instructions" | i18n }} + } + +
    +
    } - - - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - - - - - - - - - {{ row.name }} - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ row.subTitle }} - - - - - - - - {{ "instructions" | i18n }} - -
    -
    - -
    +
    + }
    diff --git a/apps/web/src/app/dirt/reports/pages/reports-home.component.html b/apps/web/src/app/dirt/reports/pages/reports-home.component.html index 9101933bc40..ee3caae4212 100644 --- a/apps/web/src/app/dirt/reports/pages/reports-home.component.html +++ b/apps/web/src/app/dirt/reports/pages/reports-home.component.html @@ -3,5 +3,5 @@

    {{ "reportsDesc" | i18n }}

    - +
    diff --git a/apps/web/src/app/dirt/reports/pages/reports-home.component.ts b/apps/web/src/app/dirt/reports/pages/reports-home.component.ts index 25cf663ba7e..5dd7f1d3ec0 100644 --- a/apps/web/src/app/dirt/reports/pages/reports-home.component.ts +++ b/apps/web/src/app/dirt/reports/pages/reports-home.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, OnInit, signal } from "@angular/core"; import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -16,7 +16,7 @@ import { ReportEntry, ReportVariant } from "../shared"; standalone: false, }) export class ReportsHomeComponent implements OnInit { - reports: ReportEntry[]; + readonly reports = signal([]); constructor( private billingAccountProfileStateService: BillingAccountProfileStateService, @@ -32,7 +32,7 @@ export class ReportsHomeComponent implements OnInit { ? ReportVariant.Enabled : ReportVariant.RequiresPremium; - this.reports = [ + this.reports.set([ { ...reports[ReportType.ExposedPasswords], variant: reportRequiresPremium, @@ -57,6 +57,6 @@ export class ReportsHomeComponent implements OnInit { ...reports[ReportType.DataBreach], variant: ReportVariant.Enabled, }, - ]; + ]); } } diff --git a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html index 62496dfad00..f08af8bda01 100644 --- a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html @@ -2,111 +2,115 @@

    {{ "reusedPasswordsReportDesc" | i18n }}

    -
    - - {{ "loading" | i18n }} -
    -
    - - {{ "noReusedPasswords" | i18n }} - - - - {{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - @if (showFilterToggle && !isAdminConsoleActive) { - @if (canDisplayToggleGroup()) { - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - } @else { - - } - } - - - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - {{ "timesReused" | i18n }} - - - - - - - - {{ row.name }} - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ row.subTitle }} - - - + + {{ "loading" | i18n }} +
    + } @else { +
    + @if (!ciphers.length) { + + {{ "noReusedPasswords" | i18n }} + + } @else { + + {{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - {{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }} - - - - - -
    + @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + @if (!isAdminConsoleActive) { + + + {{ "name" | i18n }} + {{ "owner" | i18n }} + {{ "timesReused" | i18n }} + + } + + + + + + @if (!organization || canManageCipher(row)) { + {{ row.name }} + } @else { + {{ row.name }} + } + @if (!organization && row.organizationId) { + + {{ "shared" | i18n }} + } + @if (row.hasAttachments) { + + {{ "attachments" | i18n }} + } +
    + {{ row.subTitle }} + + + @if (!organization) { + + + } + + + + {{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }} + + +
    +
    + } +
    + } diff --git a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html index 276508b3801..810c1e384b0 100644 --- a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html @@ -2,105 +2,109 @@

    {{ "unsecuredWebsitesReportDesc" | i18n }}

    -
    - - {{ "loading" | i18n }} -
    -
    - - {{ "noUnsecuredWebsites" | i18n }} - - - - {{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - @if (showFilterToggle && !isAdminConsoleActive) { - @if (canDisplayToggleGroup()) { - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - } @else { - - } - } - - - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - - - - - - - - {{ row.name }} - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ row.subTitle }} - - - + + {{ "loading" | i18n }} +
    + } @else { +
    + @if (!ciphers.length) { + + {{ "noUnsecuredWebsites" | i18n }} + + } @else { + + {{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - -
    + @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + @if (!isAdminConsoleActive) { + + + {{ "name" | i18n }} + {{ "owner" | i18n }} + + } + + + + + + @if (!organization || canManageCipher(row)) { + {{ row.name }} + } @else { + {{ row.name }} + } + @if (!organization && row.organizationId) { + + {{ "shared" | i18n }} + } + @if (row.hasAttachments) { + + {{ "attachments" | i18n }} + } +
    + {{ row.subTitle }} + + + @if (!organization) { + + + } + +
    +
    + } +
    + } diff --git a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html index 96bae4c3e0a..d96d083ffe0 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html @@ -2,115 +2,123 @@

    {{ "weakPasswordsReportDesc" | i18n }}

    -
    - - {{ "loading" | i18n }} -
    -
    - - {{ "noWeakPasswords" | i18n }} - - - - {{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - @if (showFilterToggle && !isAdminConsoleActive) { - @if (canDisplayToggleGroup()) { - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - } @else { - - } - } - - - - - {{ "name" | i18n }} - - {{ "owner" | i18n }} - - - {{ "weakness" | i18n }} - - - - - - - - - {{ row.name }} - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ row.subTitle }} - - - + + {{ "loading" | i18n }} +
    + } @else { +
    + @if (!ciphers.length) { + + {{ "noWeakPasswords" | i18n }} + + } @else { + + {{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - {{ row.reportValue.label | i18n }} - - - - - -
    + @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + + + {{ "name" | i18n }} + @if (!isAdminConsoleActive) { + + {{ "owner" | i18n }} + + } + + {{ "weakness" | i18n }} + + + + + + + + @if (!organization || canManageCipher(row)) { + {{ row.name }} + } @else { + {{ row.name }} + } + @if (!organization && row.organizationId) { + + {{ "shared" | i18n }} + } + @if (row.hasAttachments) { + + {{ "attachments" | i18n }} + } +
    + {{ row.subTitle }} + + @if (!isAdminConsoleActive) { + + @if (!organization) { + + + } + + } + + + {{ row.reportValue.label | i18n }} + + +
    +
    + } +
    + } diff --git a/apps/web/src/app/dirt/reports/shared/models/report-entry.ts b/apps/web/src/app/dirt/reports/shared/models/report-entry.ts index fd1e57991fb..63edbc63f92 100644 --- a/apps/web/src/app/dirt/reports/shared/models/report-entry.ts +++ b/apps/web/src/app/dirt/reports/shared/models/report-entry.ts @@ -1,4 +1,4 @@ -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { ReportVariant } from "./report-variant"; @@ -6,6 +6,6 @@ export type ReportEntry = { title: string; description: string; route: string; - icon: Icon; + icon: BitSvg; variant: ReportVariant; }; diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html index f0318028e60..6b201e7f6ae 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html @@ -8,7 +8,7 @@ [ngClass]="{ 'tw-grayscale': disabled }" >
    - +
    diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts index 87c005ea46b..2f4934381b9 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { Component, Input } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { ReportVariant } from "../models/report-variant"; @@ -25,7 +25,7 @@ export class ReportCardComponent { @Input() route: string; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() icon: Icon; + @Input() icon: BitSvg; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() variant: ReportVariant; diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts index 93ea79c8418..4f442dc9380 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts @@ -14,7 +14,7 @@ import { BaseCardComponent, CardContentComponent, I18nMockService, - IconModule, + SvgModule, } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; @@ -31,7 +31,7 @@ export default { JslibModule, BadgeModule, CardContentComponent, - IconModule, + SvgModule, RouterTestingModule, PremiumBadgeComponent, BaseCardComponent, diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html index 2a03bf78dd4..4726eb5c42f 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html @@ -1,13 +1,15 @@
    -
    - -
    + @for (report of reports(); track report) { +
    + +
    + }
    diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.ts b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.ts index 509e2f3b872..095484de0ce 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.ts +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.ts @@ -1,18 +1,13 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { ReportEntry } from "../models/report-entry"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "app-report-list", templateUrl: "report-list.component.html", standalone: false, }) export class ReportListComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() reports: ReportEntry[]; + readonly reports = input([]); } diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts index 5a95e332816..9686644bd74 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts @@ -12,7 +12,7 @@ import { BadgeModule, BaseCardComponent, CardContentComponent, - IconModule, + SvgModule, } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; @@ -31,7 +31,7 @@ export default { JslibModule, BadgeModule, RouterTestingModule, - IconModule, + SvgModule, PremiumBadgeComponent, CardContentComponent, BaseCardComponent, diff --git a/apps/web/src/app/layouts/header/web-header.stories.ts b/apps/web/src/app/layouts/header/web-header.stories.ts index 88c98f01e6c..3b3b28b8e45 100644 --- a/apps/web/src/app/layouts/header/web-header.stories.ts +++ b/apps/web/src/app/layouts/header/web-header.stories.ts @@ -24,7 +24,7 @@ import { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, @@ -94,7 +94,7 @@ export default { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, TabsModule, diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 90207f59ad4..33bce661c65 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -16,7 +16,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { BillingFreeFamiliesNavItemComponent } from "../billing/shared/billing-free-families-nav-item.component"; @@ -32,7 +32,7 @@ import { WebLayoutModule } from "./web-layout.module"; RouterModule, JslibModule, WebLayoutModule, - IconModule, + SvgModule, BillingFreeFamiliesNavItemComponent, ], }) diff --git a/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts b/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts index 6d051a91f7e..6873700e2bc 100644 --- a/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts +++ b/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts @@ -4,7 +4,7 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an import { delay, of, startWith } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { LinkModule, IconModule, ProgressModule } from "@bitwarden/components"; +import { LinkModule, SvgModule, ProgressModule } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../core/tests"; @@ -16,7 +16,7 @@ export default { component: OnboardingComponent, decorators: [ moduleMetadata({ - imports: [JslibModule, RouterModule, LinkModule, IconModule, ProgressModule], + imports: [JslibModule, RouterModule, LinkModule, SvgModule, ProgressModule], declarations: [OnboardingTaskComponent], }), applicationConfig({ diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index 6012e4867e1..b83555fd84e 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -18,7 +18,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, MultiSelectModule, @@ -63,7 +63,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, MultiSelectModule, @@ -99,7 +99,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, MultiSelectModule, diff --git a/apps/web/src/app/tools/send/send-access/access.component.html b/apps/web/src/app/tools/send/send-access/access.component.html index b86933410b8..6cda4cf4d7d 100644 --- a/apps/web/src/app/tools/send/send-access/access.component.html +++ b/apps/web/src/app/tools/send/send-access/access.component.html @@ -1,4 +1,4 @@ -@switch (viewState) { +@switch (viewState()) { @case ("auth") { } @@ -6,6 +6,7 @@ (SendViewState.Auth); id: string; key: string; + sendAccessToken: SendAccessToken | null = null; sendAccessResponse: SendAccessResponse | null = null; sendAccessRequest: SendAccessRequest = new SendAccessRequest(); - constructor(private route: ActivatedRoute) {} + constructor( + private route: ActivatedRoute, + private destroyRef: DestroyRef, + ) {} - async ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.params.subscribe(async (params) => { + ngOnInit() { + this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { this.id = params.sendId; this.key = params.key; - - if (this.id && this.key) { - this.viewState = SendViewState.View; - this.sendAccessResponse = null; - this.sendAccessRequest = new SendAccessRequest(); - } }); } onAuthRequired() { - this.viewState = SendViewState.Auth; + this.viewState.set(SendViewState.Auth); } - onAccessGranted(event: { response: SendAccessResponse; request: SendAccessRequest }) { + onAccessGranted(event: { + response?: SendAccessResponse; + request?: SendAccessRequest; + accessToken?: SendAccessToken; + }) { this.sendAccessResponse = event.response; this.sendAccessRequest = event.request; - this.viewState = SendViewState.View; + this.sendAccessToken = event.accessToken; + this.viewState.set(SendViewState.View); } } diff --git a/apps/web/src/app/tools/send/send-access/send-access-email.component.html b/apps/web/src/app/tools/send/send-access/send-access-email.component.html new file mode 100644 index 00000000000..ee5a03670bb --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-access-email.component.html @@ -0,0 +1,35 @@ +@if (!enterOtp()) { + + {{ "email" | i18n }} + + +
    + +
    +} @else { + + {{ "verificationCode" | i18n }} + + +
    + +
    +} diff --git a/apps/web/src/app/tools/send/send-access/send-access-email.component.ts b/apps/web/src/app/tools/send/send-access/send-access-email.component.ts new file mode 100644 index 00000000000..b1374cd6c66 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-access-email.component.ts @@ -0,0 +1,35 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; + +import { SharedModule } from "../../../shared"; + +@Component({ + selector: "app-send-access-email", + templateUrl: "send-access-email.component.html", + imports: [SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendAccessEmailComponent implements OnInit, OnDestroy { + protected readonly formGroup = input.required(); + protected readonly enterOtp = input.required(); + protected email: FormControl; + protected otp: FormControl; + + readonly loading = input.required(); + + constructor() {} + + ngOnInit() { + this.email = new FormControl("", Validators.required); + this.otp = new FormControl("", Validators.required); + this.formGroup().addControl("email", this.email); + this.formGroup().addControl("otp", this.otp); + } + + ngOnDestroy() { + this.formGroup().removeControl("email"); + this.formGroup().removeControl("otp"); + } +} diff --git a/apps/web/src/app/tools/send/send-access/send-access-file.component.html b/apps/web/src/app/tools/send/send-access/send-access-file.component.html index 8cbe6a975ef..4088b3a7034 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-file.component.html +++ b/apps/web/src/app/tools/send/send-access/send-access-file.component.html @@ -1,5 +1,5 @@ -

    {{ send.file.fileName }}

    +

    {{ send().file.fileName }}

    diff --git a/apps/web/src/app/tools/send/send-access/send-access-file.component.ts b/apps/web/src/app/tools/send/send-access/send-access-file.component.ts index dc7689f011a..bb45e83d110 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-file.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-file.component.ts @@ -1,8 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, Input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -15,40 +18,39 @@ import { ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send-access-file", templateUrl: "send-access-file.component.html", imports: [SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendAccessFileComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() send: SendAccessView; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() decKey: SymmetricCryptoKey; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() accessRequest: SendAccessRequest; + readonly send = input(null); + readonly decKey = input(null); + readonly accessRequest = input(null); + readonly accessToken = input(null); + constructor( private i18nService: I18nService, private toastService: ToastService, private encryptService: EncryptService, private fileDownloadService: FileDownloadService, private sendApiService: SendApiService, + private configService: ConfigService, ) {} protected download = async () => { - if (this.send == null || this.decKey == null) { + const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + const accessToken = this.accessToken(); + const accessRequest = this.accessRequest(); + const authMissing = (sendEmailOtp && !accessToken) || (!sendEmailOtp && !accessRequest); + if (this.send() == null || this.decKey() == null || authMissing) { return; } - const downloadData = await this.sendApiService.getSendFileDownloadData( - this.send, - this.accessRequest, - ); + const downloadData = sendEmailOtp + ? await this.sendApiService.getSendFileDownloadDataV2(this.send(), accessToken) + : await this.sendApiService.getSendFileDownloadData(this.send(), accessRequest); if (Utils.isNullOrWhitespace(downloadData.url)) { this.toastService.showToast({ @@ -71,9 +73,9 @@ export class SendAccessFileComponent { try { const encBuf = await EncArrayBuffer.fromResponse(response); - const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey); + const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey()); this.fileDownloadService.download({ - fileName: this.send.file.fileName, + fileName: this.send().file.fileName, blobData: decBuf, downloadMethod: "save", }); diff --git a/apps/web/src/app/tools/send/send-access/send-access-password.component.html b/apps/web/src/app/tools/send/send-access/send-access-password.component.html index 8bb2c306010..deca7ad3d24 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-password.component.html +++ b/apps/web/src/app/tools/send/send-access/send-access-password.component.html @@ -1,28 +1,19 @@

    {{ "sendProtectedPassword" | i18n }}

    {{ "sendProtectedPasswordDontKnow" | i18n }}

    -
    - - {{ "password" | i18n }} - - - -
    - -
    + + {{ "password" | i18n }} + + + +
    +
    diff --git a/apps/web/src/app/tools/send/send-access/send-access-password.component.ts b/apps/web/src/app/tools/send/send-access/send-access-password.component.ts index 34b183be10e..b2ee222ae86 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-password.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-password.component.ts @@ -1,43 +1,30 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { Subject, takeUntil } from "rxjs"; +import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; import { SharedModule } from "../../../shared"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send-access-password", templateUrl: "send-access-password.component.html", imports: [SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendAccessPasswordComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); - protected formGroup = this.formBuilder.group({ - password: ["", [Validators.required]], - }); + protected readonly formGroup = input.required(); + protected password: FormControl; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() loading: boolean; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref - @Output() setPasswordEvent = new EventEmitter(); + readonly loading = input.required(); - constructor(private formBuilder: FormBuilder) {} + constructor() {} - async ngOnInit() { - this.formGroup.controls.password.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe((val) => { - this.setPasswordEvent.emit(val); - }); + ngOnInit() { + this.password = new FormControl("", Validators.required); + this.formGroup().addControl("password", this.password); } ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); + this.formGroup().removeControl("password"); } } diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.html b/apps/web/src/app/tools/send/send-access/send-auth.component.html index 21a6de50ba8..c3e90cea4ea 100644 --- a/apps/web/src/app/tools/send/send-access/send-auth.component.html +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.html @@ -1,14 +1,38 @@ -
    -
    -

    {{ "sendAccessUnavailable" | i18n }}

    +@if (loading()) { +
    + + {{ "loading" | i18n }}
    -
    -

    {{ "unexpectedErrorSend" | i18n }}

    -
    - - +} + + @if (error()) { +
    +

    {{ "unexpectedErrorSend" | i18n }}

    +
    + } + @if (unavailable()) { +
    +

    {{ "sendAccessUnavailable" | i18n }}

    +
    + } @else { + @switch (sendAuthType()) { + @case (authType.Password) { + + } + @case (authType.Email) { + + } + } + } diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.ts b/apps/web/src/app/tools/send/send-access/send-auth.component.ts index b360044a8b6..13e82bd4cfa 100644 --- a/apps/web/src/app/tools/send/send-access/send-auth.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.ts @@ -1,86 +1,211 @@ -import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, OnInit, output, signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; +import { + emailAndOtpRequiredEmailSent, + emailInvalid, + emailRequired, + otpInvalid, + passwordHashB64Invalid, + passwordHashB64Required, + SendAccessDomainCredentials, + SendAccessToken, + SendHashedPasswordB64, + sendIdInvalid, + SendOtp, + SendTokenService, +} from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +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 { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; +import { SendAccessEmailComponent } from "./send-access-email.component"; import { SendAccessPasswordComponent } from "./send-access-password.component"; @Component({ selector: "app-send-auth", templateUrl: "send-auth.component.html", - imports: [SendAccessPasswordComponent, SharedModule], + imports: [SendAccessPasswordComponent, SendAccessEmailComponent, SharedModule], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SendAuthComponent { - readonly id = input.required(); - readonly key = input.required(); +export class SendAuthComponent implements OnInit { + protected readonly id = input.required(); + protected readonly key = input.required(); - accessGranted = output<{ - response: SendAccessResponse; - request: SendAccessRequest; + protected accessGranted = output<{ + response?: SendAccessResponse; + request?: SendAccessRequest; + accessToken?: SendAccessToken; }>(); - loading = false; - error = false; - unavailable = false; - password?: string; + authType = AuthType; - private accessRequest!: SendAccessRequest; + private expiredAuthAttempts = 0; + + readonly loading = signal(false); + readonly error = signal(false); + readonly unavailable = signal(false); + readonly sendAuthType = signal(AuthType.None); + readonly enterOtp = signal(false); + + sendAccessForm = this.formBuilder.group<{ password?: string; email?: string; otp?: string }>({}); constructor( private cryptoFunctionService: CryptoFunctionService, private sendApiService: SendApiService, private toastService: ToastService, private i18nService: I18nService, + private formBuilder: FormBuilder, + private configService: ConfigService, + private sendTokenService: SendTokenService, ) {} - async onSubmit(password: string) { - this.password = password; - this.loading = true; - this.error = false; - this.unavailable = false; + ngOnInit() { + void this.onSubmit(); + } + async onSubmit() { + this.loading.set(true); + this.unavailable.set(false); + this.error.set(false); + const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + if (sendEmailOtp) { + await this.attemptV2Access(); + } else { + await this.attemptV1Access(); + } + this.loading.set(false); + } + + private async attemptV1Access() { try { - const keyArray = Utils.fromUrlB64ToArray(this.key()); - this.accessRequest = new SendAccessRequest(); - - const passwordHash = await this.cryptoFunctionService.pbkdf2( - this.password, - keyArray, - "sha256", - SEND_KDF_ITERATIONS, - ); - this.accessRequest.password = Utils.fromBufferToB64(passwordHash); - - const sendResponse = await this.sendApiService.postSendAccess(this.id(), this.accessRequest); - this.accessGranted.emit({ response: sendResponse, request: this.accessRequest }); + const accessRequest = new SendAccessRequest(); + if (this.sendAuthType() === AuthType.Password) { + const password = this.sendAccessForm.value.password; + if (password == null) { + return; + } + accessRequest.password = await this.getPasswordHashB64(password, this.key()); + } + const sendResponse = await this.sendApiService.postSendAccess(this.id(), accessRequest); + this.accessGranted.emit({ request: accessRequest, response: sendResponse }); } catch (e) { if (e instanceof ErrorResponse) { - if (e.statusCode === 404) { - this.unavailable = true; - } else if (e.statusCode === 400) { + if (e.statusCode === 401) { + this.sendAuthType.set(AuthType.Password); + } else if (e.statusCode === 404) { + this.unavailable.set(true); + } else { + this.error.set(true); this.toastService.showToast({ variant: "error", title: this.i18nService.t("errorOccurred"), message: e.message, }); - } else { - this.error = true; } } else { - this.error = true; + this.error.set(true); } - } finally { - this.loading = false; } } + + private async attemptV2Access(): Promise { + let sendAccessCreds: SendAccessDomainCredentials | null = null; + if (this.sendAuthType() === AuthType.Email) { + const email = this.sendAccessForm.value.email; + if (email == null) { + return; + } + if (!this.enterOtp()) { + sendAccessCreds = { kind: "email", email }; + } else { + const otp = this.sendAccessForm.value.otp as SendOtp; + if (otp == null) { + return; + } + sendAccessCreds = { kind: "email_otp", email, otp }; + } + } else if (this.sendAuthType() === AuthType.Password) { + const password = this.sendAccessForm.value.password; + if (password == null) { + return; + } + const passwordHashB64 = await this.getPasswordHashB64(password, this.key()); + sendAccessCreds = { kind: "password", passwordHashB64 }; + } + const response = !sendAccessCreds + ? await firstValueFrom(this.sendTokenService.tryGetSendAccessToken$(this.id())) + : await firstValueFrom(this.sendTokenService.getSendAccessToken$(this.id(), sendAccessCreds)); + if (response instanceof SendAccessToken) { + this.expiredAuthAttempts = 0; + this.accessGranted.emit({ accessToken: response }); + } else if (response.kind === "expired") { + if (this.expiredAuthAttempts > 2) { + return; + } + this.expiredAuthAttempts++; + await this.attemptV2Access(); + } else if (response.kind === "expected_server") { + this.expiredAuthAttempts = 0; + if (emailRequired(response.error)) { + this.sendAuthType.set(AuthType.Email); + } else if (emailAndOtpRequiredEmailSent(response.error) || emailInvalid(response.error)) { + this.enterOtp.set(true); + } else if (otpInvalid(response.error)) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidVerificationCode"), + }); + } else if (passwordHashB64Required(response.error)) { + this.sendAuthType.set(AuthType.Password); + } else if (passwordHashB64Invalid(response.error)) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidSendPassword"), + }); + } else if (sendIdInvalid(response.error)) { + this.unavailable.set(true); + } else { + this.error.set(true); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: response.error.error_description ?? "", + }); + } + } else { + this.expiredAuthAttempts = 0; + this.error.set(true); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: response.error, + }); + } + } + + private async getPasswordHashB64(password: string, key: string) { + const keyArray = Utils.fromUrlB64ToArray(key); + const passwordHash = await this.cryptoFunctionService.pbkdf2( + password, + keyArray, + "sha256", + SEND_KDF_ITERATIONS, + ); + return Utils.fromBufferToB64(passwordHash) as SendHashedPasswordB64; + } } diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.html b/apps/web/src/app/tools/send/send-access/send-view.component.html index dd0b770b261..3536499ddad 100644 --- a/apps/web/src/app/tools/send/send-access/send-view.component.html +++ b/apps/web/src/app/tools/send/send-access/send-view.component.html @@ -1,41 +1,13 @@ - - {{ "viewSendHiddenEmailWarning" | i18n }} - {{ - "learnMore" | i18n - }}. - +@if (hideEmail()) { + + {{ "viewSendHiddenEmailWarning" | i18n }} + {{ + "learnMore" | i18n + }} + +} - -
    -

    {{ "sendAccessUnavailable" | i18n }}

    -
    -
    -

    {{ "unexpectedErrorSend" | i18n }}

    -
    -
    -

    - {{ send.name }} -

    -
    - - - - - - - - -

    - Expires: {{ expirationDate | date: "medium" }} -

    -
    -
    - +@if (loading()) {
    {{ "loading" | i18n }}
    -
    +} @else { + @if (unavailable()) { +
    +

    {{ "sendAccessUnavailable" | i18n }}

    +
    + } + @if (error()) { +
    +

    {{ "unexpectedErrorSend" | i18n }}

    +
    + } + @if (send()) { +
    +

    + {{ send().name }} +

    +
    + @switch (send().type) { + @case (sendType.Text) { + + } + @case (sendType.File) { + + } + } + @if (expirationDate()) { +

    Expires: {{ expirationDate() | date: "medium" }}

    + } +
    + } +} diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.ts b/apps/web/src/app/tools/send/send-access/send-view.component.ts index 060dc1958b1..1ab9a121ace 100644 --- a/apps/web/src/app/tools/send/send-access/send-view.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-view.component.ts @@ -1,13 +1,17 @@ import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, + computed, input, OnInit, output, + signal, } from "@angular/core"; +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +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 { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; @@ -34,17 +38,25 @@ import { SendAccessTextComponent } from "./send-access-text.component"; export class SendViewComponent implements OnInit { readonly id = input.required(); readonly key = input.required(); + readonly accessToken = input(null); readonly sendResponse = input(null); readonly accessRequest = input(new SendAccessRequest()); authRequired = output(); - send: SendAccessView | null = null; + readonly send = signal(null); + readonly expirationDate = computed(() => this.send()?.expirationDate ?? null); + readonly creatorIdentifier = computed( + () => this.send()?.creatorIdentifier ?? null, + ); + readonly hideEmail = computed( + () => this.send() != null && this.creatorIdentifier() == null, + ); + readonly loading = signal(false); + readonly unavailable = signal(false); + readonly error = signal(false); + sendType = SendType; - loading = true; - unavailable = false; - error = false; - hideEmail = false; decKey!: SymmetricCryptoKey; constructor( @@ -53,50 +65,48 @@ export class SendViewComponent implements OnInit { private toastService: ToastService, private i18nService: I18nService, private layoutWrapperDataService: AnonLayoutWrapperDataService, - private cdRef: ChangeDetectorRef, + private configService: ConfigService, ) {} - get expirationDate() { - if (this.send == null || this.send.expirationDate == null) { - return null; - } - return this.send.expirationDate; - } - - get creatorIdentifier() { - if (this.send == null || this.send.creatorIdentifier == null) { - return null; - } - return this.send.creatorIdentifier; - } - - async ngOnInit() { - await this.load(); + ngOnInit() { + void this.load(); } private async load() { - this.unavailable = false; - this.error = false; - this.hideEmail = false; - this.loading = true; - - let response = this.sendResponse(); + this.loading.set(true); + this.unavailable.set(false); + this.error.set(false); try { - if (!response) { - response = await this.sendApiService.postSendAccess(this.id(), this.accessRequest()); + const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + let response: SendAccessResponse; + if (sendEmailOtp) { + const accessToken = this.accessToken(); + if (!accessToken) { + this.authRequired.emit(); + return; + } + response = await this.sendApiService.postSendAccessV2(accessToken); + } else { + const sendResponse = this.sendResponse(); + if (!sendResponse) { + this.authRequired.emit(); + return; + } + response = sendResponse; } - const keyArray = Utils.fromUrlB64ToArray(this.key()); const sendAccess = new SendAccess(response); this.decKey = await this.keyService.makeSendKey(keyArray); - this.send = await sendAccess.decrypt(this.decKey); + const decSend = await sendAccess.decrypt(this.decKey); + this.send.set(decSend); } catch (e) { + this.send.set(null); if (e instanceof ErrorResponse) { if (e.statusCode === 401) { this.authRequired.emit(); } else if (e.statusCode === 404) { - this.unavailable = true; + this.unavailable.set(true); } else if (e.statusCode === 400) { this.toastService.showToast({ variant: "error", @@ -104,28 +114,23 @@ export class SendViewComponent implements OnInit { message: e.message, }); } else { - this.error = true; + this.error.set(true); } } else { - this.error = true; + this.error.set(true); } + } finally { + this.loading.set(false); } - this.loading = false; - this.hideEmail = - this.creatorIdentifier == null && !this.loading && !this.unavailable && !response; - - this.hideEmail = this.send != null && this.creatorIdentifier == null; - - if (this.creatorIdentifier != null) { + const creatorIdentifier = this.creatorIdentifier(); + if (creatorIdentifier != null) { this.layoutWrapperDataService.setAnonLayoutWrapperData({ pageSubtitle: { key: "sendAccessCreatorIdentifier", - placeholders: [this.creatorIdentifier], + placeholders: [creatorIdentifier], }, }); } - - this.cdRef.markForCheck(); } } diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html index a484f210f62..90210df4658 100644 --- a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html @@ -8,7 +8,7 @@ >
    - +
    diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts index 54d62b8414a..51603724c57 100644 --- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts @@ -12,7 +12,7 @@ import { ActivatedRoute } from "@angular/router"; import { map, Observable, of, tap } from "rxjs"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; -import { ButtonComponent, IconModule } from "@bitwarden/components"; +import { ButtonComponent, SvgModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { @@ -24,7 +24,7 @@ import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manua @Component({ selector: "vault-browser-extension-prompt", templateUrl: "./browser-extension-prompt.component.html", - imports: [CommonModule, I18nPipe, ButtonComponent, IconModule, ManuallyOpenExtensionComponent], + imports: [CommonModule, I18nPipe, ButtonComponent, SvgModule, ManuallyOpenExtensionComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class BrowserExtensionPromptComponent implements OnInit, OnDestroy { diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html index d15cdaa712b..7da964f5fdb 100644 --- a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html +++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html @@ -1,8 +1,8 @@

    {{ "openExtensionFromToolbarPart1" | i18n }} - + > {{ "openExtensionFromToolbarPart2" | i18n }}

    diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts index 435e847f6e9..e4db0a55097 100644 --- a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts +++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts @@ -1,14 +1,14 @@ import { Component, ChangeDetectionStrategy } from "@angular/core"; import { BitwardenIcon } from "@bitwarden/assets/svg"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: "vault-manually-open-extension", templateUrl: "./manually-open-extension.component.html", - imports: [I18nPipe, IconModule], + imports: [I18nPipe, SvgModule], }) export class ManuallyOpenExtensionComponent { protected BitwardenIcon = BitwardenIcon; diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html index 8cfd394b854..d8cd562ac61 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html @@ -31,7 +31,7 @@
    - +

    {{ diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts index cfc1961c4d8..1b2c0144549 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts @@ -18,7 +18,7 @@ import { CenterPositionStrategy, DialogRef, DialogService, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -52,7 +52,7 @@ type SetupExtensionState = UnionOfValues; JslibModule, ButtonComponent, LinkModule, - IconModule, + SvgModule, RouterModule, AddExtensionVideosComponent, ManuallyOpenExtensionComponent, diff --git a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts index 46f2b5da735..9fcb6f0cec1 100644 --- a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts +++ b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts @@ -12,7 +12,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { CenterPositionStrategy, @@ -148,11 +147,16 @@ export class BulkDeleteDialogComponent { } private async deleteCiphersAdmin(ciphers: string[]): Promise { - const deleteRequest = new CipherBulkDeleteRequest(ciphers, this.organization.id); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); if (this.permanent) { - return await this.apiService.deleteManyCiphersAdmin(deleteRequest); + await this.cipherService.deleteManyWithServer(ciphers, userId, true, this.organization.id); } else { - return await this.apiService.putDeleteManyCiphersAdmin(deleteRequest); + await this.cipherService.softDeleteManyWithServer( + ciphers, + userId, + true, + this.organization.id, + ); } } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 532757852a3..b07de88baf9 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -33,7 +33,7 @@ import { EmptyTrash, FavoritesIcon, ItemTypes, - Icon, + BitSvg, } from "@bitwarden/assets/svg"; import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -160,7 +160,7 @@ type EmptyStateType = "trash" | "favorites" | "archive"; type EmptyStateItem = { title: string; description: string; - icon: Icon; + icon: BitSvg; }; type EmptyStateMap = Record; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 5a83bc75810..872509a81c2 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical":{ + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/webpack.base.js b/apps/web/webpack.base.js index c39e68bfa83..e1feb68e24b 100644 --- a/apps/web/webpack.base.js +++ b/apps/web/webpack.base.js @@ -326,7 +326,7 @@ module.exports.buildConfig = function buildConfig(params) { https://*.paypal.com https://www.paypalobjects.com https://q.stripe.com - https://haveibeenpwned.com + https://logos.haveibeenpwned.com ;media-src 'self' https://assets.bitwarden.com diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html index a2b231ffd48..80e76acac1d 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html @@ -10,22 +10,34 @@ {{ "claimDomain" | i18n }} - - {{ data.orgDomain.domainName }} - - - {{ "domainStatusUnderVerification" | i18n }} + {{ "domainStatusPending" | i18n }} {{ "domainStatusClaimed" | i18n }}
    +
    +

    {{ "automaticDomainClaimProcess1" | i18n }}

    +

    + {{ "automaticDomainClaimProcess2" | i18n }} + + {{ "accountOwnershipChange" | i18n }} + + + {{ "automaticDomainClaimProcessEnd" | i18n }} +

    +
    {{ "domainName" | i18n }} - {{ "claimDomainNameInputHint" | i18n }} @@ -40,14 +52,6 @@ (click)="copyDnsTxt()" > - - - {{ "automaticDomainClaimProcess" | i18n }} -
    diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index d02b44af1be..a2330be4c6f 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -21,7 +21,6 @@ import { Provider } from "@bitwarden/common/admin-console/models/domain/provider import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -72,7 +71,6 @@ export class vNextMembersComponent { private activatedRoute = inject(ActivatedRoute); private providerService = inject(ProviderService); private accountService = inject(AccountService); - private configService = inject(ConfigService); private environmentService = inject(EnvironmentService); private providerActionsService = inject(ProviderActionsService); private memberActionsService = inject(MemberActionsService); @@ -94,7 +92,7 @@ export class vNextMembersComponent { protected statusToggle = new BehaviorSubject(undefined); protected readonly dataSource: WritableSignal = signal( - new ProvidersTableDataSource(this.configService, this.environmentService), + new ProvidersTableDataSource(this.environmentService), ); protected readonly firstLoaded: WritableSignal = signal(false); @@ -177,7 +175,7 @@ export class vNextMembersComponent { // Capture the original count BEFORE enforcing the limit const originalInvitedCount = allInvitedUsers.length; - // When feature flag is enabled, limit invited users and uncheck the excess + // In cloud environments, limit invited users and uncheck the excess let checkedInvitedUsers: ProviderUser[]; if (this.dataSource().isIncreasedBulkLimitEnabled()) { checkedInvitedUsers = this.dataSource().limitAndUncheckExcess( @@ -198,7 +196,7 @@ export class vNextMembersComponent { } try { - // When feature flag is enabled, show toast instead of dialog + // In cloud environments, show toast instead of dialog if (this.dataSource().isIncreasedBulkLimitEnabled()) { await this.apiService.postManyProviderUserReinvite( providerId, @@ -226,7 +224,7 @@ export class vNextMembersComponent { }); } } else { - // Feature flag disabled - show legacy dialog + // In self-hosted environments, show legacy dialog const request = this.apiService.postManyProviderUserReinvite( providerId, new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)), diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts index 65f85616dfa..4e391ae8dad 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts @@ -7,13 +7,13 @@ import { combineLatest, map, Observable, Subject, switchMap } from "rxjs"; import { takeUntil } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { BusinessUnitPortalLogo, Icon, ProviderPortalLogo } from "@bitwarden/assets/svg"; +import { BusinessUnitPortalLogo, BitSvg, ProviderPortalLogo } from "@bitwarden/assets/svg"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderType } from "@bitwarden/common/admin-console/enums"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; import { TaxIdWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components"; import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; @@ -31,7 +31,7 @@ import { ProviderWarningsService } from "../../billing/providers/warnings/servic RouterModule, JslibModule, WebLayoutModule, - IconModule, + SvgModule, TaxIdWarningComponent, ], }) @@ -41,7 +41,7 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); protected provider$: Observable; - protected logo$: Observable; + protected logo$: Observable; protected canAccessBilling$: Observable; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html index cb8eaea80c3..ff148098cc6 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html @@ -1,10 +1,10 @@
    - + >

    - + >

    = signal([]); private readonly _atRiskCipherIds: Signal = signal([]); private readonly _hasCriticalApplications: Signal = signal(false); + private readonly _reportGeneratedAt: Signal = signal( + undefined, + ); // Computed properties readonly tasksCount = computed(() => this._tasks().length); @@ -81,8 +82,24 @@ export class PasswordChangeMetricComponent implements OnInit { } const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); - const assignedIdSet = new Set(inProgressTasks.map((task) => task.cipherId)); - const unassignedIds = atRiskIds.filter((id) => !assignedIdSet.has(id)); + const inProgressTaskIds = new Set(inProgressTasks.map((task) => task.cipherId)); + + const reportGeneratedAt = this._reportGeneratedAt(); + const completedTasksAfterReportGeneration = reportGeneratedAt + ? tasks.filter( + (task) => + task.status === SecurityTaskStatus.Completed && + new Date(task.revisionDate) >= reportGeneratedAt, + ) + : []; + const completedTaskIds = new Set( + completedTasksAfterReportGeneration.map((task) => task.cipherId), + ); + + // find cipher ids from last report that do not have a corresponding in progress task (awaiting password reset) OR completed task + const unassignedIds = atRiskIds.filter( + (id) => !inProgressTaskIds.has(id) && !completedTaskIds.has(id), + ); return unassignedIds.length; }); @@ -110,36 +127,26 @@ export class PasswordChangeMetricComponent implements OnInit { constructor( private allActivitiesService: AllActivitiesService, private i18nService: I18nService, - private injector: Injector, private riskInsightsDataService: RiskInsightsDataService, protected securityTasksService: AccessIntelligenceSecurityTasksService, private toastService: ToastService, ) { - // Setup the _tasks signal by manually passing in the injector - this._tasks = toSignal(this.securityTasksService.tasks$, { - initialValue: [], - injector: this.injector, - }); - // Setup the _atRiskCipherIds signal by manually passing in the injector + this._tasks = toSignal(this.securityTasksService.tasks$, { initialValue: [] }); this._atRiskCipherIds = toSignal( this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, - { - initialValue: [], - injector: this.injector, - }, + { initialValue: [] }, ); - this._hasCriticalApplications = toSignal( this.riskInsightsDataService.criticalReportResults$.pipe( - takeUntilDestroyed(this.destroyRef), map((report) => { return report != null && (report.reportData?.length ?? 0) > 0; }), ), - { - initialValue: false, - injector: this.injector, - }, + { initialValue: false }, + ); + this._reportGeneratedAt = toSignal( + this.riskInsightsDataService.enrichedReportData$.pipe(map((report) => report?.creationDate)), + { initialValue: undefined }, ); effect(() => { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts index 15d927a7714..619858fdffe 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { @@ -25,7 +24,6 @@ import { DarkImageSourceDirective } from "@bitwarden/vault"; selector: "dirt-assign-tasks-view", templateUrl: "./assign-tasks-view.component.html", imports: [ - CommonModule, ButtonModule, TypographyModule, I18nPipe, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts index 4de8ecd9cd0..796c0acf220 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, @@ -79,7 +78,6 @@ export type NewApplicationsDialogResultType = selector: "dirt-new-applications-dialog", templateUrl: "./new-applications-dialog.component.html", imports: [ - CommonModule, ButtonModule, DialogModule, TypographyModule, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html new file mode 100644 index 00000000000..092cc4b73d8 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html @@ -0,0 +1,128 @@ +@if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) { + +} @else { + @let drawerDetails = dataService.drawerDetails$ | async; +

    +

    {{ "allApplications" | i18n }}

    +
    +
    +
    + {{ + "atRiskMembers" | i18n + }} +
    + {{ applicationSummary().totalAtRiskMemberCount }} + {{ + "cardMetrics" | i18n: applicationSummary().totalMemberCount + }} +
    +
    +

    + +

    +
    +
    +
    +
    +
    + {{ "atRiskApplications" | i18n }} +
    + {{ applicationSummary().totalAtRiskApplicationCount }} + {{ + "cardMetrics" | i18n: applicationSummary().totalApplicationCount + }} +
    +
    +

    + +

    +
    +
    +
    +
    +
    + + + + + +
    + + +
    +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts new file mode 100644 index 00000000000..0a393b26974 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts @@ -0,0 +1,221 @@ +import { + Component, + DestroyRef, + inject, + OnInit, + ChangeDetectionStrategy, + signal, + computed, +} from "@angular/core"; +import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatest, debounceTime, startWith } from "rxjs"; + +import { Security } from "@bitwarden/assets/svg"; +import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; +import { + OrganizationReportSummary, + ReportStatus, +} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ButtonModule, + IconButtonModule, + LinkModule, + NoItemsModule, + SearchModule, + TableDataSource, + ToastService, + TypographyModule, + ChipSelectComponent, +} from "@bitwarden/components"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; + +import { + ApplicationTableDataSource, + AppTableRowScrollableComponent, +} from "../shared/app-table-row-scrollable.component"; +import { ReportLoadingComponent } from "../shared/report-loading.component"; + +export const ApplicationFilterOption = { + All: "all", + Critical: "critical", + NonCritical: "nonCritical", +} as const; + +export type ApplicationFilterOption = + (typeof ApplicationFilterOption)[keyof typeof ApplicationFilterOption]; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "dirt-applications", + templateUrl: "./applications.component.html", + imports: [ + ReportLoadingComponent, + HeaderModule, + LinkModule, + SearchModule, + PipesModule, + NoItemsModule, + SharedModule, + AppTableRowScrollableComponent, + IconButtonModule, + TypographyModule, + ButtonModule, + ReactiveFormsModule, + ChipSelectComponent, + ], +}) +export class ApplicationsComponent implements OnInit { + destroyRef = inject(DestroyRef); + + protected ReportStatusEnum = ReportStatus; + protected noItemsIcon = Security; + + // Standard properties + protected readonly dataSource = new TableDataSource(); + protected readonly searchControl = new FormControl("", { nonNullable: true }); + + // Template driven properties + protected readonly selectedUrls = signal(new Set()); + protected readonly markingAsCritical = signal(false); + protected readonly applicationSummary = signal(createNewSummaryData()); + protected readonly criticalApplicationsCount = signal(0); + protected readonly totalApplicationsCount = signal(0); + protected readonly nonCriticalApplicationsCount = computed(() => { + return this.totalApplicationsCount() - this.criticalApplicationsCount(); + }); + + // filter related properties + protected readonly selectedFilter = signal(ApplicationFilterOption.All); + protected selectedFilterObservable = toObservable(this.selectedFilter); + protected readonly ApplicationFilterOption = ApplicationFilterOption; + protected readonly filterOptions = computed(() => [ + { + label: this.i18nService.t("critical", this.criticalApplicationsCount()), + value: ApplicationFilterOption.Critical, + }, + { + label: this.i18nService.t("notCritical", this.nonCriticalApplicationsCount()), + value: ApplicationFilterOption.NonCritical, + }, + ]); + + constructor( + protected i18nService: I18nService, + protected activatedRoute: ActivatedRoute, + protected toastService: ToastService, + protected dataService: RiskInsightsDataService, + ) {} + + async ngOnInit() { + this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (report) => { + if (report != null) { + this.applicationSummary.set(report.summaryData); + + // Map the report data to include the iconCipher for each application + const tableDataWithIcon = report.reportData.map((app) => ({ + ...app, + iconCipher: + app.cipherIds.length > 0 + ? this.dataService.getCipherIcon(app.cipherIds[0]) + : undefined, + })); + this.dataSource.data = tableDataWithIcon; + this.totalApplicationsCount.set(report.reportData.length); + } else { + this.dataSource.data = []; + } + }, + error: () => { + this.dataSource.data = []; + }, + }); + + this.dataService.criticalReportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (criticalReport) => { + if (criticalReport != null) { + this.criticalApplicationsCount.set(criticalReport.reportData.length); + } else { + this.criticalApplicationsCount.set(0); + } + }, + }); + + combineLatest([ + this.searchControl.valueChanges.pipe(startWith("")), + this.selectedFilterObservable, + ]) + .pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)) + .subscribe(([searchText, selectedFilter]) => { + let filterFunction = (app: ApplicationTableDataSource) => true; + + if (selectedFilter === ApplicationFilterOption.Critical) { + filterFunction = (app) => app.isMarkedAsCritical; + } else if (selectedFilter === ApplicationFilterOption.NonCritical) { + filterFunction = (app) => !app.isMarkedAsCritical; + } + + this.dataSource.filter = (app) => + filterFunction(app) && + app.applicationName.toLowerCase().includes(searchText.toLowerCase()); + }); + } + + setFilterApplicationsByStatus(value: ApplicationFilterOption) { + this.selectedFilter.set(value); + } + + isMarkedAsCriticalItem(applicationName: string) { + return this.selectedUrls().has(applicationName); + } + + markAppsAsCritical = async () => { + this.markingAsCritical.set(true); + const count = this.selectedUrls().size; + + this.dataService + .saveCriticalApplications(Array.from(this.selectedUrls())) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()), + }); + this.selectedUrls.set(new Set()); + this.markingAsCritical.set(false); + }, + error: () => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("applicationsMarkedAsCriticalFail"), + }); + }, + }); + }; + + showAppAtRiskMembers = async (applicationName: string) => { + await this.dataService.setDrawerForAppAtRiskMembers(applicationName); + }; + + onCheckboxChange = (applicationName: string, event: Event) => { + const isChecked = (event.target as HTMLInputElement).checked; + this.selectedUrls.update((selectedUrls) => { + const nextSelected = new Set(selectedUrls); + if (isChecked) { + nextSelected.add(applicationName); + } else { + nextSelected.delete(applicationName); + } + return nextSelected; + }); + }; +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html index 42600671e8c..59aa680fa4e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html @@ -6,12 +6,11 @@ {{ title() }}
    -
    - {{ description() }} -
    + @if (description()) { +
    + {{ description() }} +
    + } @if (benefits().length > 0) {
    @for (benefit of benefits(); track $index) { @@ -38,69 +37,74 @@
    } -
    - -
    + @if (buttonText() && buttonAction()) { +
    + +
    + }
    -
    -
    - @if (videoSrc()) { - - } @else if (icon()) { -
    - +
    + @if (videoSrc()) { +
    - } + > + } @else if (icon()) { +
    + +
    + } +
    -
    - -
    -
    - @if (videoSrc()) { - - } @else if (icon()) { -
    - +
    + @if (videoSrc()) { +
    - } + > + } @else if (icon()) { +
    + +
    + } +
    -
    + }
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts index 54d97e984ec..a9ad86dc67c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts @@ -1,17 +1,16 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input, isDevMode, OnInit } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; -import { ButtonModule, IconModule } from "@bitwarden/components"; +import { BitSvg } from "@bitwarden/assets/svg"; +import { ButtonModule, SvgModule } from "@bitwarden/components"; @Component({ selector: "empty-state-card", templateUrl: "./empty-state-card.component.html", - imports: [CommonModule, IconModule, ButtonModule], + imports: [SvgModule, ButtonModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class EmptyStateCardComponent implements OnInit { - readonly icon = input(null); + readonly icon = input(null); readonly videoSrc = input(null); readonly title = input(""); readonly description = input(""); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index dfbd49d95f7..1e58d334288 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -44,10 +44,11 @@
    -
    - {{ "reviewAtRiskPasswords" | i18n }} -
    - @let isRunningReport = dataService.isGeneratingReport$ | async; + @if (appsCount > 0) { +
    + {{ "reviewAtRiskPasswords" | i18n }} +
    + }
    @@ -62,7 +63,6 @@ } - - -
    @@ -88,6 +81,11 @@ + @if (milestone11Enabled) { + + + + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index b307c91d29f..657bdb87d4a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -21,6 +21,8 @@ import { ReportStatus, RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -38,6 +40,7 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { AllActivityComponent } from "./activity/all-activity.component"; import { AllApplicationsComponent } from "./all-applications/all-applications.component"; +import { ApplicationsComponent } from "./all-applications/applications.component"; import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; import { EmptyStateCardComponent } from "./empty-state-card.component"; import { RiskInsightsTabType } from "./models/risk-insights.models"; @@ -53,6 +56,7 @@ type ProgressStep = ReportProgress | null; templateUrl: "./risk-insights.component.html", imports: [ AllApplicationsComponent, + ApplicationsComponent, AsyncActionsModule, ButtonModule, CommonModule, @@ -77,6 +81,7 @@ type ProgressStep = ReportProgress | null; export class RiskInsightsComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); protected ReportStatusEnum = ReportStatus; + protected milestone11Enabled: boolean = false; tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllActivity; @@ -114,6 +119,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { protected dialogService: DialogService, private fileDownloadService: FileDownloadService, private logService: LogService, + private configService: ConfigService, ) { this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllActivity; @@ -121,6 +127,10 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { } async ngOnInit() { + this.milestone11Enabled = await this.configService.getFeatureFlag( + FeatureFlag.Milestone11AppPageImprovements, + ); + this.route.paramMap .pipe( takeUntilDestroyed(this.destroyRef), diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html index 0494f77bd46..0a72c76a550 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html @@ -12,28 +12,32 @@ {{ "totalMembers" | i18n }} - - - - - - - + @if (showRowCheckBox) { + + @if (!row.isMarkedAsCritical) { + + } + @if (row.isMarkedAsCritical) { + + } + + } + @if (!showRowCheckBox) { + + @if (row.isMarkedAsCritical) { + + } + + } - + @if (row.iconCipher) { + + } {{ row.memberCount }} - - - - - - - + @if (showRowMenuForCriticalApps) { + + + + + + + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts index f3cb89dff55..45b28dae470 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { Component, input } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -19,7 +18,7 @@ const ProgressStepConfig = Object.freeze({ // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "dirt-report-loading", - imports: [CommonModule, JslibModule, ProgressModule], + imports: [JslibModule, ProgressModule], templateUrl: "./report-loading.component.html", }) export class ReportLoadingComponent { diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html index 9e14023d21b..8127c6a0343 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html @@ -1,21 +1,22 @@
      -
    • - -
    • + @for (integration of integrations; track integration) { +
    • + +
    • + }
    diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html index a35df3677bb..14f20a0b71c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html @@ -24,28 +24,32 @@ @if (organization?.useScim || organization?.useDirectory) { -
    -

    - {{ "scimIntegration" | i18n }} -

    -

    - {{ "scimIntegrationDescStart" | i18n }} - {{ "scimIntegration" | i18n }} - {{ "scimIntegrationDescEnd" | i18n }} -

    - -
    -
    -

    - {{ "bwdc" | i18n }} -

    -

    {{ "bwdcDesc" | i18n }}

    - -
    + @if (organization?.useScim) { +
    +

    + {{ "scimIntegration" | i18n }} +

    +

    + {{ "scimIntegrationDescStart" | i18n }} + {{ "scimIntegration" | i18n }} + {{ "scimIntegrationDescEnd" | i18n }} +

    + +
    + } + @if (organization?.useDirectory) { +
    +

    + {{ "bwdc" | i18n }} +

    +

    {{ "bwdcDesc" | i18n }}

    + +
    + }
    } diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html index 0200e206327..440e955a226 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html @@ -1,21 +1,17 @@ - + @let isLoading = isLoading$ | async; - + @if (!isLoading) { + + + }
    @@ -24,7 +20,7 @@

    - +@if (isLoading) {

    {{ "loading" | i18n }}

    -
    - - - {{ "members" | i18n }} - {{ "groups" | i18n }} - {{ "collections" | i18n }} - {{ "items" | i18n }} - - - -
    - -
    - - -
    - {{ row.email }} +} @else { + + + {{ "members" | i18n }} + {{ "groups" | i18n }} + + {{ "collections" | i18n }} + + {{ "items" | i18n }} + + + +
    + +
    + +
    + {{ row.email }} +
    -
    - - {{ row.groupsCount }} - {{ row.collectionsCount }} - {{ row.itemsCount }} - - + + {{ row.groupsCount }} + {{ row.collectionsCount }} + {{ row.itemsCount }} + + +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts index f2e0d48fe1d..241f02fce7e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts @@ -2,7 +2,7 @@ import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { map, concatMap, firstValueFrom } from "rxjs"; -import { Icon, DeactivatedOrg } from "@bitwarden/assets/svg"; +import { BitSvg, DeactivatedOrg } from "@bitwarden/assets/svg"; import { getOrganizationById, OrganizationService, @@ -23,7 +23,7 @@ export class OrgSuspendedComponent { private route: ActivatedRoute, ) {} - protected DeactivatedOrg: Icon = DeactivatedOrg; + protected DeactivatedOrg: BitSvg = DeactivatedOrg; protected organizationName$ = this.route.params.pipe( concatMap(async (params) => { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); diff --git a/eslint.config.mjs b/eslint.config.mjs index d93c0e8c397..9d0cc99b8cd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -207,6 +207,7 @@ export default tseslint.config( "error", { ignoreIfHas: ["bitPasswordInputToggle"] }, ], + "@bitwarden/components/no-bwi-class-usage": "warn", }, }, diff --git a/libs/angular/src/auth/components/two-factor-icon.component.html b/libs/angular/src/auth/components/two-factor-icon.component.html index 14558700757..555176225af 100644 --- a/libs/angular/src/auth/components/two-factor-icon.component.html +++ b/libs/angular/src/auth/components/two-factor-icon.component.html @@ -1,6 +1,6 @@
    - +
    0) { - throw new DynamicContentNotAllowedError(); - } - - return new Icon(strings[0]); -} diff --git a/libs/assets/src/svg/index.ts b/libs/assets/src/svg/index.ts index 9f86a14f772..6a0fff490ff 100644 --- a/libs/assets/src/svg/index.ts +++ b/libs/assets/src/svg/index.ts @@ -1,2 +1,2 @@ export * from "./svgs"; -export * from "./icon-service"; +export * from "./svg"; diff --git a/libs/assets/src/svg/icon-service.spec.ts b/libs/assets/src/svg/svg.spec.ts similarity index 69% rename from libs/assets/src/svg/icon-service.spec.ts rename to libs/assets/src/svg/svg.spec.ts index 2561c85aefa..2d8401f0b5d 100644 --- a/libs/assets/src/svg/icon-service.spec.ts +++ b/libs/assets/src/svg/svg.spec.ts @@ -1,5 +1,5 @@ -import * as IconExports from "./icon-service"; -import { DynamicContentNotAllowedError, isIcon, svgIcon } from "./icon-service"; +import * as IconExports from "./svg"; +import { DynamicContentNotAllowedError, isBitSvg, svg } from "./svg"; describe("Icon", () => { it("exports should not expose Icon class", () => { @@ -8,13 +8,13 @@ describe("Icon", () => { describe("isIcon", () => { it("should return true when input is icon", () => { - const result = isIcon(svgIcon`icon`); + const result = isBitSvg(svg`icon`); expect(result).toBe(true); }); it("should return false when input is not an icon", () => { - const result = isIcon({ svg: "not an icon" }); + const result = isBitSvg({ svg: "not an icon" }); expect(result).toBe(false); }); @@ -24,13 +24,13 @@ describe("Icon", () => { it("should throw when attempting to create dynamic icons", () => { const dynamic = "some user input"; - const f = () => svgIcon`static and ${dynamic}`; + const f = () => svg`static and ${dynamic}`; expect(f).toThrow(DynamicContentNotAllowedError); }); it("should return svg content when supplying icon with svg string", () => { - const icon = svgIcon`safe static content`; + const icon = svg`safe static content`; expect(icon.svg).toBe("safe static content"); }); diff --git a/libs/assets/src/svg/svg.ts b/libs/assets/src/svg/svg.ts new file mode 100644 index 00000000000..71324ea4bac --- /dev/null +++ b/libs/assets/src/svg/svg.ts @@ -0,0 +1,25 @@ +class BitSvg { + constructor(readonly svg: string) {} +} + +// We only export the type to prohibit the creation of Svgs without using +// the `svg` template literal tag. +export type { BitSvg }; + +export function isBitSvg(svgContent: unknown): svgContent is BitSvg { + return svgContent instanceof BitSvg; +} + +export class DynamicContentNotAllowedError extends Error { + constructor() { + super("Dynamic content in icons is not allowed due to risk of user-injected XSS."); + } +} + +export function svg(strings: TemplateStringsArray, ...values: unknown[]): BitSvg { + if (values.length > 0) { + throw new DynamicContentNotAllowedError(); + } + + return new BitSvg(strings[0]); +} diff --git a/libs/assets/src/svg/svgs/account-warning.icon.ts b/libs/assets/src/svg/svgs/account-warning.icon.ts index 80e29dad870..81bf62d6e64 100644 --- a/libs/assets/src/svg/svgs/account-warning.icon.ts +++ b/libs/assets/src/svg/svgs/account-warning.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const AccountWarning = svgIcon` +export const AccountWarning = svg` diff --git a/libs/assets/src/svg/svgs/active-send.icon.ts b/libs/assets/src/svg/svgs/active-send.icon.ts index 3b12ee865d1..3016466e062 100644 --- a/libs/assets/src/svg/svgs/active-send.icon.ts +++ b/libs/assets/src/svg/svgs/active-send.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ActiveSendIcon = svgIcon` +export const ActiveSendIcon = svg` diff --git a/libs/assets/src/svg/svgs/admin-console.ts b/libs/assets/src/svg/svgs/admin-console.ts index 3e8f47ec4a5..146c834b442 100644 --- a/libs/assets/src/svg/svgs/admin-console.ts +++ b/libs/assets/src/svg/svgs/admin-console.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const AdminConsoleLogo = svgIcon` +const AdminConsoleLogo = svg` diff --git a/libs/assets/src/svg/svgs/auto-confirmation.ts b/libs/assets/src/svg/svgs/auto-confirmation.ts index 2a1416a5d25..5d0e0dd380c 100644 --- a/libs/assets/src/svg/svgs/auto-confirmation.ts +++ b/libs/assets/src/svg/svgs/auto-confirmation.ts @@ -1,5 +1,5 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const AutoConfirmSvg = svgIcon` +export const AutoConfirmSvg = svg` `; diff --git a/libs/assets/src/svg/svgs/background-left-illustration.ts b/libs/assets/src/svg/svgs/background-left-illustration.ts index a34f31f1621..f091f905c64 100644 --- a/libs/assets/src/svg/svgs/background-left-illustration.ts +++ b/libs/assets/src/svg/svgs/background-left-illustration.ts @@ -1,5 +1,5 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BackgroundLeftIllustration = svgIcon` +export const BackgroundLeftIllustration = svg` `; diff --git a/libs/assets/src/svg/svgs/background-right-illustration.ts b/libs/assets/src/svg/svgs/background-right-illustration.ts index 1c488f7242d..8f3bbba3462 100644 --- a/libs/assets/src/svg/svgs/background-right-illustration.ts +++ b/libs/assets/src/svg/svgs/background-right-illustration.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BackgroundRightIllustration = svgIcon` +export const BackgroundRightIllustration = svg` diff --git a/libs/assets/src/svg/svgs/bitwarden-icon.ts b/libs/assets/src/svg/svgs/bitwarden-icon.ts index 203460952b5..43aea78ced6 100644 --- a/libs/assets/src/svg/svgs/bitwarden-icon.ts +++ b/libs/assets/src/svg/svgs/bitwarden-icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BitwardenIcon = svgIcon` +export const BitwardenIcon = svg` diff --git a/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts b/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts index 9c1c7248ec6..85d0a471a6e 100644 --- a/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts +++ b/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BitwardenLogo = svgIcon` +export const BitwardenLogo = svg` Bitwarden diff --git a/libs/assets/src/svg/svgs/browser-extension.ts b/libs/assets/src/svg/svgs/browser-extension.ts index c15a536c007..2c40c584255 100644 --- a/libs/assets/src/svg/svgs/browser-extension.ts +++ b/libs/assets/src/svg/svgs/browser-extension.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BrowserExtensionIcon = svgIcon` +export const BrowserExtensionIcon = svg` diff --git a/libs/assets/src/svg/svgs/business-unit-portal.ts b/libs/assets/src/svg/svgs/business-unit-portal.ts index db3a6b8ef4f..cd06afcbf9a 100644 --- a/libs/assets/src/svg/svgs/business-unit-portal.ts +++ b/libs/assets/src/svg/svgs/business-unit-portal.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const BusinessUnitPortalLogo = svgIcon` +const BusinessUnitPortalLogo = svg` diff --git a/libs/assets/src/svg/svgs/business-welcome.icon.ts b/libs/assets/src/svg/svgs/business-welcome.icon.ts index 06c4950ec18..1d1caed8d47 100644 --- a/libs/assets/src/svg/svgs/business-welcome.icon.ts +++ b/libs/assets/src/svg/svgs/business-welcome.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BusinessWelcome = svgIcon` +export const BusinessWelcome = svg` diff --git a/libs/assets/src/svg/svgs/carousel-icon.ts b/libs/assets/src/svg/svgs/carousel-icon.ts index e29fd952098..4d645ad8029 100644 --- a/libs/assets/src/svg/svgs/carousel-icon.ts +++ b/libs/assets/src/svg/svgs/carousel-icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const CarouselIcon = svgIcon` +export const CarouselIcon = svg` diff --git a/libs/assets/src/svg/svgs/credit-card.icon.ts b/libs/assets/src/svg/svgs/credit-card.icon.ts index e334766fac7..dd0eb6a121a 100644 --- a/libs/assets/src/svg/svgs/credit-card.icon.ts +++ b/libs/assets/src/svg/svgs/credit-card.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const CreditCardIcon = svgIcon` +export const CreditCardIcon = svg` diff --git a/libs/assets/src/svg/svgs/deactivated-org.ts b/libs/assets/src/svg/svgs/deactivated-org.ts index 75b25e3fd27..d2566712a98 100644 --- a/libs/assets/src/svg/svgs/deactivated-org.ts +++ b/libs/assets/src/svg/svgs/deactivated-org.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DeactivatedOrg = svgIcon` +export const DeactivatedOrg = svg` diff --git a/libs/assets/src/svg/svgs/devices.icon.ts b/libs/assets/src/svg/svgs/devices.icon.ts index 7c97df48657..a3a4aa06442 100644 --- a/libs/assets/src/svg/svgs/devices.icon.ts +++ b/libs/assets/src/svg/svgs/devices.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DevicesIcon = svgIcon` +export const DevicesIcon = svg` diff --git a/libs/assets/src/svg/svgs/domain.icon.ts b/libs/assets/src/svg/svgs/domain.icon.ts index 04bd173be98..af47b1930d7 100644 --- a/libs/assets/src/svg/svgs/domain.icon.ts +++ b/libs/assets/src/svg/svgs/domain.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DomainIcon = svgIcon` +export const DomainIcon = svg` diff --git a/libs/assets/src/svg/svgs/empty-trash.ts b/libs/assets/src/svg/svgs/empty-trash.ts index d6c0043d880..da48bd69c3e 100644 --- a/libs/assets/src/svg/svgs/empty-trash.ts +++ b/libs/assets/src/svg/svgs/empty-trash.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const EmptyTrash = svgIcon` +export const EmptyTrash = svg` diff --git a/libs/assets/src/svg/svgs/favorites.icon.ts b/libs/assets/src/svg/svgs/favorites.icon.ts index 4725d0b0a7c..8777eaeef88 100644 --- a/libs/assets/src/svg/svgs/favorites.icon.ts +++ b/libs/assets/src/svg/svgs/favorites.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const FavoritesIcon = svgIcon` +export const FavoritesIcon = svg` diff --git a/libs/assets/src/svg/svgs/gear.ts b/libs/assets/src/svg/svgs/gear.ts index 261c6d262e1..c04dc8e1a17 100644 --- a/libs/assets/src/svg/svgs/gear.ts +++ b/libs/assets/src/svg/svgs/gear.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const GearIcon = svgIcon` +export const GearIcon = svg` diff --git a/libs/assets/src/svg/svgs/generator.ts b/libs/assets/src/svg/svgs/generator.ts index 52368ddc204..26b09f19455 100644 --- a/libs/assets/src/svg/svgs/generator.ts +++ b/libs/assets/src/svg/svgs/generator.ts @@ -1,12 +1,12 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const GeneratorInactive = svgIcon` +export const GeneratorInactive = svg` `; -export const GeneratorActive = svgIcon` +export const GeneratorActive = svg` diff --git a/libs/assets/src/svg/svgs/item-types.ts b/libs/assets/src/svg/svgs/item-types.ts index 50ed51bd018..b066df72b0d 100644 --- a/libs/assets/src/svg/svgs/item-types.ts +++ b/libs/assets/src/svg/svgs/item-types.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ItemTypes = svgIcon` +export const ItemTypes = svg` diff --git a/libs/assets/src/svg/svgs/lock.icon.ts b/libs/assets/src/svg/svgs/lock.icon.ts index 9d73ad6294c..f42630739f1 100644 --- a/libs/assets/src/svg/svgs/lock.icon.ts +++ b/libs/assets/src/svg/svgs/lock.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const LockIcon = svgIcon` +export const LockIcon = svg` diff --git a/libs/assets/src/svg/svgs/login-cards.ts b/libs/assets/src/svg/svgs/login-cards.ts index 3a43b1a0121..13c456a1658 100644 --- a/libs/assets/src/svg/svgs/login-cards.ts +++ b/libs/assets/src/svg/svgs/login-cards.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const LoginCards = svgIcon` +export const LoginCards = svg` diff --git a/libs/assets/src/svg/svgs/no-credentials.icon.ts b/libs/assets/src/svg/svgs/no-credentials.icon.ts index bfecfd4834c..da7795db808 100644 --- a/libs/assets/src/svg/svgs/no-credentials.icon.ts +++ b/libs/assets/src/svg/svgs/no-credentials.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoCredentialsIcon = svgIcon` +export const NoCredentialsIcon = svg` diff --git a/libs/assets/src/svg/svgs/no-folders.ts b/libs/assets/src/svg/svgs/no-folders.ts index c8858ca83e5..7facc01e4d6 100644 --- a/libs/assets/src/svg/svgs/no-folders.ts +++ b/libs/assets/src/svg/svgs/no-folders.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoFolders = svgIcon` +export const NoFolders = svg` diff --git a/libs/assets/src/svg/svgs/no-results.ts b/libs/assets/src/svg/svgs/no-results.ts index 5f914ad213c..75ad485181f 100644 --- a/libs/assets/src/svg/svgs/no-results.ts +++ b/libs/assets/src/svg/svgs/no-results.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoResults = svgIcon` +export const NoResults = svg` diff --git a/libs/assets/src/svg/svgs/no-send.icon.ts b/libs/assets/src/svg/svgs/no-send.icon.ts index a246c0177f8..a7125caabf6 100644 --- a/libs/assets/src/svg/svgs/no-send.icon.ts +++ b/libs/assets/src/svg/svgs/no-send.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoSendsIcon = svgIcon` +export const NoSendsIcon = svg` diff --git a/libs/assets/src/svg/svgs/party.ts b/libs/assets/src/svg/svgs/party.ts index efa5331f4fc..991f4a3deda 100644 --- a/libs/assets/src/svg/svgs/party.ts +++ b/libs/assets/src/svg/svgs/party.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const Party = svgIcon` +export const Party = svg` diff --git a/libs/assets/src/svg/svgs/password-manager.ts b/libs/assets/src/svg/svgs/password-manager.ts index 5b19562e022..aa7e8ecc52d 100644 --- a/libs/assets/src/svg/svgs/password-manager.ts +++ b/libs/assets/src/svg/svgs/password-manager.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const PasswordManagerLogo = svgIcon` +const PasswordManagerLogo = svg` diff --git a/libs/assets/src/svg/svgs/provider-portal.ts b/libs/assets/src/svg/svgs/provider-portal.ts index fad2ce6b864..97d23633a9e 100644 --- a/libs/assets/src/svg/svgs/provider-portal.ts +++ b/libs/assets/src/svg/svgs/provider-portal.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const ProviderPortalLogo = svgIcon` +const ProviderPortalLogo = svg` diff --git a/libs/assets/src/svg/svgs/registration-check-email.icon.ts b/libs/assets/src/svg/svgs/registration-check-email.icon.ts index ae4cf3098e6..006a60bc7c0 100644 --- a/libs/assets/src/svg/svgs/registration-check-email.icon.ts +++ b/libs/assets/src/svg/svgs/registration-check-email.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RegistrationCheckEmailIcon = svgIcon` +export const RegistrationCheckEmailIcon = svg` diff --git a/libs/assets/src/svg/svgs/registration-user-add.icon.ts b/libs/assets/src/svg/svgs/registration-user-add.icon.ts index 7428daa5848..358412c38eb 100644 --- a/libs/assets/src/svg/svgs/registration-user-add.icon.ts +++ b/libs/assets/src/svg/svgs/registration-user-add.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RegistrationUserAddIcon = svgIcon` +export const RegistrationUserAddIcon = svg` diff --git a/libs/assets/src/svg/svgs/report-breach.icon.ts b/libs/assets/src/svg/svgs/report-breach.icon.ts index 83dd6c72b82..e926388e333 100644 --- a/libs/assets/src/svg/svgs/report-breach.icon.ts +++ b/libs/assets/src/svg/svgs/report-breach.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportBreach = svgIcon` +export const ReportBreach = svg` diff --git a/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts b/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts index 0309eb643d9..590e7d7d1a1 100644 --- a/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts +++ b/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportExposedPasswords = svgIcon` +export const ReportExposedPasswords = svg` diff --git a/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts b/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts index 487381ccaa9..831a6570812 100644 --- a/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts +++ b/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportUnsecuredWebsites = svgIcon` +export const ReportUnsecuredWebsites = svg` diff --git a/libs/assets/src/svg/svgs/restricted-view.ts b/libs/assets/src/svg/svgs/restricted-view.ts index 5eec1a4a972..7bf40467ac6 100644 --- a/libs/assets/src/svg/svgs/restricted-view.ts +++ b/libs/assets/src/svg/svgs/restricted-view.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RestrictedView = svgIcon` +export const RestrictedView = svg` diff --git a/libs/assets/src/svg/svgs/secrets-manager-alt.ts b/libs/assets/src/svg/svgs/secrets-manager-alt.ts index 98640803ca9..70fa7d6386c 100644 --- a/libs/assets/src/svg/svgs/secrets-manager-alt.ts +++ b/libs/assets/src/svg/svgs/secrets-manager-alt.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SecretsManagerAlt = svgIcon` +export const SecretsManagerAlt = svg` diff --git a/libs/assets/src/svg/svgs/secrets-manager.ts b/libs/assets/src/svg/svgs/secrets-manager.ts index 62b54174c55..3cd66df59e3 100644 --- a/libs/assets/src/svg/svgs/secrets-manager.ts +++ b/libs/assets/src/svg/svgs/secrets-manager.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const SecretsManagerLogo = svgIcon` +const SecretsManagerLogo = svg` diff --git a/libs/assets/src/svg/svgs/security.ts b/libs/assets/src/svg/svgs/security.ts index 6e475b25ab7..119d0164599 100644 --- a/libs/assets/src/svg/svgs/security.ts +++ b/libs/assets/src/svg/svgs/security.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const Security = svgIcon` +export const Security = svg` diff --git a/libs/assets/src/svg/svgs/send.ts b/libs/assets/src/svg/svgs/send.ts index f09f59a5388..309844f9fd9 100644 --- a/libs/assets/src/svg/svgs/send.ts +++ b/libs/assets/src/svg/svgs/send.ts @@ -1,12 +1,12 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SendInactive = svgIcon` +export const SendInactive = svg` `; -export const SendActive = svgIcon` +export const SendActive = svg` diff --git a/libs/assets/src/svg/svgs/settings.ts b/libs/assets/src/svg/svgs/settings.ts index 3b54bbbd88c..b0e42821c6b 100644 --- a/libs/assets/src/svg/svgs/settings.ts +++ b/libs/assets/src/svg/svgs/settings.ts @@ -1,13 +1,13 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SettingsInactive = svgIcon` +export const SettingsInactive = svg` `; -export const SettingsActive = svgIcon` +export const SettingsActive = svg` diff --git a/libs/assets/src/svg/svgs/shield.ts b/libs/assets/src/svg/svgs/shield.ts index af626a98e9d..bd5f9e02d1d 100644 --- a/libs/assets/src/svg/svgs/shield.ts +++ b/libs/assets/src/svg/svgs/shield.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const BitwardenShield = svgIcon` +const BitwardenShield = svg` diff --git a/libs/assets/src/svg/svgs/sso-key.icon.ts b/libs/assets/src/svg/svgs/sso-key.icon.ts index ad81c707449..d6e45b13b42 100644 --- a/libs/assets/src/svg/svgs/sso-key.icon.ts +++ b/libs/assets/src/svg/svgs/sso-key.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SsoKeyIcon = svgIcon` +export const SsoKeyIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts index 622875b59f2..11d2fafb745 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthAuthenticatorIcon = svgIcon` +export const TwoFactorAuthAuthenticatorIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts index 5bf43334d18..a40a6418885 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts @@ -1,8 +1,10 @@ -// this svg includes the Duo logo, which contains colors not part of our bitwarden theme colors /* eslint-disable @bitwarden/components/require-theme-colors-in-svg */ -import { svgIcon } from "../icon-service"; -export const TwoFactorAuthDuoIcon = svgIcon` +// this svg includes the Duo logo, which contains colors not part of our bitwarden theme colors + +import { svg } from "../svg"; + +export const TwoFactorAuthDuoIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts index 20709a8a1e1..8fdee85da82 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthEmailIcon = svgIcon` +export const TwoFactorAuthEmailIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts index 0e467bf1901..3eab3bb00c6 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthSecurityKeyFailedIcon = svgIcon` +export const TwoFactorAuthSecurityKeyFailedIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts index f10068b735b..830db83f3e8 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthSecurityKeyIcon = svgIcon` +export const TwoFactorAuthSecurityKeyIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts index b9114259584..9f0decb1f36 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthWebAuthnIcon = svgIcon` +export const TwoFactorAuthWebAuthnIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts index d4d38c363ae..6368442cde6 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts @@ -1,8 +1,9 @@ -// this svg includes the Yubico logo, which contains colors not part of our bitwarden theme colors /* eslint-disable @bitwarden/components/require-theme-colors-in-svg */ -import { svgIcon } from "../icon-service"; +// this svg includes the Yubico logo, which contains colors not part of our bitwarden theme colors -export const TwoFactorAuthYubicoIcon = svgIcon` +import { svg } from "../svg"; + +export const TwoFactorAuthYubicoIcon = svg` diff --git a/libs/assets/src/svg/svgs/unlocked.icon.ts b/libs/assets/src/svg/svgs/unlocked.icon.ts index 6ce40819e44..1a754733d26 100644 --- a/libs/assets/src/svg/svgs/unlocked.icon.ts +++ b/libs/assets/src/svg/svgs/unlocked.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UnlockedIcon = svgIcon` +export const UnlockedIcon = svg` diff --git a/libs/assets/src/svg/svgs/user-lock.icon.ts b/libs/assets/src/svg/svgs/user-lock.icon.ts index cc848a05769..5deead382b3 100644 --- a/libs/assets/src/svg/svgs/user-lock.icon.ts +++ b/libs/assets/src/svg/svgs/user-lock.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UserLockIcon = svgIcon` +export const UserLockIcon = svg` diff --git a/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts b/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts index 19e1aa3e6cd..c175bb78993 100644 --- a/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts +++ b/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UserVerificationBiometricsIcon = svgIcon` +export const UserVerificationBiometricsIcon = svg` diff --git a/libs/assets/src/svg/svgs/vault-open.ts b/libs/assets/src/svg/svgs/vault-open.ts index 3ad82b9bbac..52e8a971d60 100644 --- a/libs/assets/src/svg/svgs/vault-open.ts +++ b/libs/assets/src/svg/svgs/vault-open.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultOpen = svgIcon` +export const VaultOpen = svg` diff --git a/libs/assets/src/svg/svgs/vault.icon.ts b/libs/assets/src/svg/svgs/vault.icon.ts index 61ec2589b34..1f442ad0471 100644 --- a/libs/assets/src/svg/svgs/vault.icon.ts +++ b/libs/assets/src/svg/svgs/vault.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultIcon = svgIcon` +export const VaultIcon = svg` diff --git a/libs/assets/src/svg/svgs/vault.ts b/libs/assets/src/svg/svgs/vault.ts index 1c699f2ba8e..8e1acab2670 100644 --- a/libs/assets/src/svg/svgs/vault.ts +++ b/libs/assets/src/svg/svgs/vault.ts @@ -1,13 +1,13 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultInactive = svgIcon` +export const VaultInactive = svg` `; -export const VaultActive = svgIcon` +export const VaultActive = svg` diff --git a/libs/assets/src/svg/svgs/wave.icon.ts b/libs/assets/src/svg/svgs/wave.icon.ts index 6c97d0fbbb3..7b00ba0f3eb 100644 --- a/libs/assets/src/svg/svgs/wave.icon.ts +++ b/libs/assets/src/svg/svgs/wave.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const WaveIcon = svgIcon` +export const WaveIcon = svg` diff --git a/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts b/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts index e7a3e99759c..87b5173a6a7 100644 --- a/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts +++ b/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts @@ -9,7 +9,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { TwoFactorTimeoutIcon } from "@bitwarden/assets/svg"; // 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 { ButtonModule, IconModule } from "@bitwarden/components"; +import { ButtonModule, SvgModule } from "@bitwarden/components"; /** * RegistrationLinkExpiredComponentData @@ -24,7 +24,7 @@ export interface RegistrationLinkExpiredComponentData { @Component({ selector: "auth-registration-link-expired", templateUrl: "./registration-link-expired.component.html", - imports: [CommonModule, JslibModule, RouterModule, IconModule, ButtonModule], + imports: [CommonModule, JslibModule, RouterModule, SvgModule, ButtonModule], }) export class RegistrationLinkExpiredComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts index 714f6d49342..1161af836b4 100644 --- a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts +++ b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts @@ -20,7 +20,7 @@ import { ButtonModule, CheckboxModule, FormFieldModule, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -54,7 +54,7 @@ const DEFAULT_MARKETING_EMAILS_PREF_BY_REGION: Record = { CheckboxModule, ButtonModule, LinkModule, - IconModule, + SvgModule, RegistrationEnvSelectorComponent, ], }) diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html index 277ba047add..bf9482c7987 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html +++ b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html @@ -11,30 +11,30 @@ [ngSwitch]="provider.type" class="tw-w-16 md:tw-w-20 tw-mr-2 sm:tw-mr-4" > - - + - + - + - + - + + [content]="Icons.TwoFactorAuthWebAuthnIcon" + >
    {{ provider.name }} {{ provider.description }} diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts index d8b2ab2508b..53ae509f182 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts @@ -18,7 +18,7 @@ import { ButtonModule, DialogModule, DialogService, - IconModule, + SvgModule, ItemModule, TypographyModule, } from "@bitwarden/components"; @@ -39,7 +39,7 @@ export type TwoFactorOptionsDialogResult = { ButtonModule, TypographyModule, ItemModule, - IconModule, + SvgModule, ], providers: [], }) diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.html b/libs/auth/src/angular/user-verification/user-verification-form-input.component.html index 5699f3dd9a4..8e8f41c394d 100644 --- a/libs/auth/src/angular/user-verification/user-verification-form-input.component.html +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.html @@ -42,7 +42,7 @@ >
    - +

    {{ "verifyWithBiometrics" | i18n }}

    diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts index 296359c92ff..af73cc3de99 100644 --- a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts @@ -28,7 +28,7 @@ import { CalloutModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -64,7 +64,7 @@ import { ActiveClientVerificationOption } from "./active-client-verification-opt FormFieldModule, AsyncActionsModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, ButtonModule, CalloutModule, diff --git a/libs/common/src/autofill/constants/index.ts b/libs/common/src/autofill/constants/index.ts index dc79e27b6aa..f3f0077a37f 100644 --- a/libs/common/src/autofill/constants/index.ts +++ b/libs/common/src/autofill/constants/index.ts @@ -28,6 +28,41 @@ export const EVENTS = { SUBMIT: "submit", } as const; +/** + * HTML attributes observed by the MutationObserver for autofill form/field tracking. + * If you need to observe a new attribute, add it here. + */ +export const AUTOFILL_ATTRIBUTES = { + ACTION: "action", + ARIA_DESCRIBEDBY: "aria-describedby", + ARIA_DISABLED: "aria-disabled", + ARIA_HASPOPUP: "aria-haspopup", + ARIA_HIDDEN: "aria-hidden", + ARIA_LABEL: "aria-label", + ARIA_LABELLEDBY: "aria-labelledby", + AUTOCOMPLETE: "autocomplete", + AUTOCOMPLETE_TYPE: "autocompletetype", + X_AUTOCOMPLETE_TYPE: "x-autocompletetype", + CHECKED: "checked", + CLASS: "class", + DATA_LABEL: "data-label", + DATA_STRIPE: "data-stripe", + DISABLED: "disabled", + ID: "id", + MAXLENGTH: "maxlength", + METHOD: "method", + NAME: "name", + PLACEHOLDER: "placeholder", + POPOVER: "popover", + POPOVERTARGET: "popovertarget", + POPOVERTARGETACTION: "popovertargetaction", + READONLY: "readonly", + REL: "rel", + TABINDEX: "tabindex", + TITLE: "title", + TYPE: "type", +} as const; + export const ClearClipboardDelay = { Never: null as null, TenSeconds: 10, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 0086524a47f..ac5f3c10260 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -13,11 +13,11 @@ export enum FeatureFlag { /* Admin Console Team */ AutoConfirm = "pm-19934-auto-confirm-organization-users", BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration", - IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud", MembersComponentRefactor = "pm-29503-refactor-members-inheritance", /* Auth */ PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin", + SafariAccountSwitching = "pm-5594-safari-account-switching", /* Autofill */ MacOsNativeCredentialSync = "macos-native-credential-sync", @@ -59,6 +59,7 @@ export enum FeatureFlag { EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike", EventManagementForHuntress = "event-management-for-huntress", PhishingDetection = "phishing-detection", + Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements", /* Vault */ PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", @@ -70,7 +71,7 @@ export enum FeatureFlag { PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk", /* Platform */ - IpcChannelFramework = "ipc-channel-framework", + ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework", /* Innovation */ PM19148_InnovationArchive = "pm-19148-innovation-archive", @@ -103,7 +104,6 @@ export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.AutoConfirm]: FALSE, [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, - [FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE, [FeatureFlag.MembersComponentRefactor]: FALSE, /* Autofill */ @@ -122,6 +122,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE, [FeatureFlag.EventManagementForHuntress]: FALSE, [FeatureFlag.PhishingDetection]: FALSE, + [FeatureFlag.Milestone11AppPageImprovements]: FALSE, /* Vault */ [FeatureFlag.CipherKeyEncryption]: FALSE, @@ -134,6 +135,7 @@ export const DefaultFeatureFlagValue = { /* Auth */ [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, + [FeatureFlag.SafariAccountSwitching]: FALSE, /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, @@ -160,7 +162,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE, /* Platform */ - [FeatureFlag.IpcChannelFramework]: FALSE, + [FeatureFlag.ContentScriptIpcChannelFramework]: FALSE, /* Innovation */ [FeatureFlag.PM19148_InnovationArchive]: FALSE, diff --git a/libs/common/src/key-management/pin/pin-state.service.abstraction.ts b/libs/common/src/key-management/pin/pin-state.service.abstraction.ts index 4aef268c1c4..d577d75ef6f 100644 --- a/libs/common/src/key-management/pin/pin-state.service.abstraction.ts +++ b/libs/common/src/key-management/pin/pin-state.service.abstraction.ts @@ -11,6 +11,20 @@ import { PinLockType } from "./pin-lock-type"; * The PinStateService manages the storage and retrieval of PIN-related state for user accounts. */ export abstract class PinStateServiceAbstraction { + /** + * Checks if a user is enrolled into PIN unlock + * @param userId The user's id + * @throws If the user id is not provided + */ + abstract pinSet$(userId: UserId): Observable; + + /** + * Gets the user's {@link PinLockType} + * @param userId The user's id + * @throws If the user id is not provided + */ + abstract pinLockType$(userId: UserId): Observable; + /** * Gets the user's UserKey encrypted PIN * @deprecated - This is not a public API. DO NOT USE IT @@ -21,17 +35,12 @@ export abstract class PinStateServiceAbstraction { /** * Gets the user's {@link PinLockType} + * @deprecated Use {@link pinLockType$} instead * @param userId The user's id * @throws If the user id is not provided */ abstract getPinLockType(userId: UserId): Promise; - /** - * Checks if a user is enrolled into PIN unlock - * @param userId The user's id - */ - abstract isPinSet(userId: UserId): Promise; - /** * Gets the user's PIN-protected UserKey envelope, either persistent or ephemeral based on the provided PinLockType * @deprecated - This is not a public API. DO NOT USE IT diff --git a/libs/common/src/key-management/pin/pin-state.service.implementation.ts b/libs/common/src/key-management/pin/pin-state.service.implementation.ts index d5b2608f280..10046191c01 100644 --- a/libs/common/src/key-management/pin/pin-state.service.implementation.ts +++ b/libs/common/src/key-management/pin/pin-state.service.implementation.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, map, Observable } from "rxjs"; +import { combineLatest, firstValueFrom, map, Observable } from "rxjs"; import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal"; import { StateProvider } from "@bitwarden/state"; @@ -26,27 +26,36 @@ export class PinStateService implements PinStateServiceAbstraction { .pipe(map((value) => (value ? new EncString(value) : null))); } - async isPinSet(userId: UserId): Promise { + pinSet$(userId: UserId): Observable { assertNonNullish(userId, "userId"); - return (await this.getPinLockType(userId)) !== "DISABLED"; + return this.pinLockType$(userId).pipe(map((pinLockType) => pinLockType !== "DISABLED")); + } + + pinLockType$(userId: UserId): Observable { + assertNonNullish(userId, "userId"); + + return combineLatest([ + this.pinProtectedUserKeyEnvelope$(userId, "PERSISTENT").pipe(map((key) => key != null)), + this.stateProvider + .getUserState$(USER_KEY_ENCRYPTED_PIN, userId) + .pipe(map((key) => key != null)), + ]).pipe( + map(([isPersistentPinSet, isPinSet]) => { + if (isPersistentPinSet) { + return "PERSISTENT"; + } else if (isPinSet) { + return "EPHEMERAL"; + } else { + return "DISABLED"; + } + }), + ); } async getPinLockType(userId: UserId): Promise { assertNonNullish(userId, "userId"); - const isPersistentPinSet = - (await this.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null; - const isPinSet = - (await firstValueFrom(this.stateProvider.getUserState$(USER_KEY_ENCRYPTED_PIN, userId))) != - null; - - if (isPersistentPinSet) { - return "PERSISTENT"; - } else if (isPinSet) { - return "EPHEMERAL"; - } else { - return "DISABLED"; - } + return await firstValueFrom(this.pinLockType$(userId)); } async getPinProtectedUserKeyEnvelope( @@ -55,17 +64,7 @@ export class PinStateService implements PinStateServiceAbstraction { ): Promise { assertNonNullish(userId, "userId"); - if (pinLockType === "EPHEMERAL") { - return await firstValueFrom( - this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, userId), - ); - } else if (pinLockType === "PERSISTENT") { - return await firstValueFrom( - this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, userId), - ); - } else { - throw new Error(`Unsupported PinLockType: ${pinLockType}`); - } + return await firstValueFrom(this.pinProtectedUserKeyEnvelope$(userId, pinLockType)); } async setPinState( @@ -110,4 +109,19 @@ export class PinStateService implements PinStateServiceAbstraction { await this.stateProvider.setUserState(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, null, userId); } + + private pinProtectedUserKeyEnvelope$( + userId: UserId, + pinLockType: PinLockType, + ): Observable { + assertNonNullish(userId, "userId"); + + if (pinLockType === "EPHEMERAL") { + return this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, userId); + } else if (pinLockType === "PERSISTENT") { + return this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, userId); + } else { + throw new Error(`Unsupported PinLockType: ${pinLockType}`); + } + } } diff --git a/libs/common/src/key-management/pin/pin-state.service.spec.ts b/libs/common/src/key-management/pin/pin-state.service.spec.ts index 7406701c28d..42dcce9fedc 100644 --- a/libs/common/src/key-management/pin/pin-state.service.spec.ts +++ b/libs/common/src/key-management/pin/pin-state.service.spec.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal"; @@ -94,14 +94,50 @@ describe("PinStateService", () => { }); }); - describe("getPinLockType()", () => { + describe("pinSet$", () => { beforeEach(() => { jest.clearAllMocks(); }); it("should throw an error if userId is null", async () => { // Act & Assert - await expect(sut.getPinLockType(null as any)).rejects.toThrow("userId"); + expect(() => sut.pinSet$(null as any)).toThrow("userId"); + }); + + it("should return false when pin lock type is DISABLED", async () => { + // Arrange + jest.spyOn(sut, "pinLockType$").mockReturnValue(of("DISABLED")); + + // Act + const result = await firstValueFrom(sut.pinSet$(mockUserId)); + + // Assert + expect(result).toBe(false); + }); + + it.each([["PERSISTENT" as PinLockType], ["EPHEMERAL" as PinLockType]])( + "should return true when pin lock type is %s", + async (pinLockType) => { + // Arrange + jest.spyOn(sut, "pinLockType$").mockReturnValue(of(pinLockType)); + + // Act + const result = await firstValueFrom(sut.pinSet$(mockUserId)); + + // Assert + expect(result).toBe(true); + }, + ); + }); + + describe("pinLockType$", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should throw an error if userId is null", async () => { + // Act & Assert + expect(() => sut.pinLockType$(null as any)).toThrow("userId"); }); it("should return 'PERSISTENT' if a pin protected user key (persistent) is found", async () => { @@ -114,7 +150,7 @@ describe("PinStateService", () => { ); // Act - const result = await sut.getPinLockType(mockUserId); + const result = await firstValueFrom(sut.pinLockType$(mockUserId)); // Assert expect(result).toBe("PERSISTENT"); @@ -125,7 +161,7 @@ describe("PinStateService", () => { await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, mockUserKeyEncryptedPin, mockUserId); // Act - const result = await sut.getPinLockType(mockUserId); + const result = await firstValueFrom(sut.pinLockType$(mockUserId)); // Assert expect(result).toBe("EPHEMERAL"); @@ -135,7 +171,7 @@ describe("PinStateService", () => { // Arrange - don't set any PIN-related state // Act - const result = await sut.getPinLockType(mockUserId); + const result = await firstValueFrom(sut.pinLockType$(mockUserId)); // Assert expect(result).toBe("DISABLED"); @@ -151,7 +187,7 @@ describe("PinStateService", () => { await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, mockUserId); // Act - const result = await sut.getPinLockType(mockUserId); + const result = await firstValueFrom(sut.pinLockType$(mockUserId)); // Assert expect(result).toBe("DISABLED"); diff --git a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts index 697b8a1875c..44108b69513 100644 --- a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts @@ -20,10 +20,9 @@ export abstract class VaultTimeoutSettingsService { /** * Get the available vault timeout actions for the current user * - * **NOTE:** This observable is not yet connected to the state service, so it will not update when the state changes * @param userId The user id to check. If not provided, the current user is used */ - abstract availableVaultTimeoutActions$(userId?: string): Observable; + abstract availableVaultTimeoutActions$(userId?: UserId): Observable; /** * Evaluates the user's available vault timeout actions and returns a boolean representing @@ -55,5 +54,5 @@ export abstract class VaultTimeoutSettingsService { * @param userId The user id to check. If not provided, the current user is used * @returns boolean true if biometric lock is set */ - abstract isBiometricLockSet(userId?: string): Promise; + abstract isBiometricLockSet(userId?: UserId): Promise; } diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts index 3c391344f04..3fa71598e65 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts @@ -78,7 +78,8 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService = createVaultTimeoutSettingsService(defaultVaultTimeout); - biometricStateService.biometricUnlockEnabled$ = of(false); + pinStateService.pinSet$.mockReturnValue(of(false)); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false)); }); afterEach(() => { @@ -86,72 +87,121 @@ describe("VaultTimeoutSettingsService", () => { }); describe("availableVaultTimeoutActions$", () => { - it("always returns LogOut", async () => { - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + describe("when no userId provided (active user)", () => { + it("always returns LogOut", async () => { + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); - expect(result).toContain(VaultTimeoutAction.LogOut); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); + + it("contains Lock when the user has a master password", async () => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); + + expect(userDecryptionOptionsService.hasMasterPasswordById$).toHaveBeenCalledWith( + mockUserId, + ); + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => { + pinStateService.pinSet$.mockReturnValue(of(true)); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); + + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("contains Lock when the user has biometrics configured", async () => { + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true)); + biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); + + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false })); + pinStateService.pinSet$.mockReturnValue(of(false)); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false)); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); + + expect(result).not.toContain(VaultTimeoutAction.Lock); + }); + + it("should throw error when activeAccount$ is null", async () => { + accountService.activeAccountSubject.next(null); + + const result$ = vaultTimeoutSettingsService.availableVaultTimeoutActions$(); + + await expect(firstValueFrom(result$)).rejects.toThrow("Null or undefined account"); + }); }); - it("contains Lock when the user has a master password", async () => { - userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + describe("with explicit userId parameter", () => { + it("should return Lock and LogOut when provided user has master password", async () => { + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true)); - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId), + ); - expect(result).toContain(VaultTimeoutAction.Lock); - }); + expect(userDecryptionOptionsService.hasMasterPasswordById$).toHaveBeenCalledWith( + mockUserId, + ); + expect(result).toContain(VaultTimeoutAction.Lock); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); - it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => { - pinStateService.isPinSet.mockResolvedValue(true); + it("should return Lock and LogOut when provided user has PIN configured", async () => { + pinStateService.pinSet$.mockReturnValue(of(true)); - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId), + ); - expect(result).toContain(VaultTimeoutAction.Lock); - }); + expect(pinStateService.pinSet$).toHaveBeenCalledWith(mockUserId); + expect(result).toContain(VaultTimeoutAction.Lock); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); - it("contains Lock when the user has biometrics configured", async () => { - biometricStateService.biometricUnlockEnabled$ = of(true); - biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true); + it("should return Lock and LogOut when provided user has biometrics configured", async () => { + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true)); - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId), + ); - expect(result).toContain(VaultTimeoutAction.Lock); - }); + expect(biometricStateService.biometricUnlockEnabled$).toHaveBeenCalledWith(mockUserId); + expect(result).toContain(VaultTimeoutAction.Lock); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); - it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => { - userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false })); - pinStateService.isPinSet.mockResolvedValue(false); - biometricStateService.biometricUnlockEnabled$ = of(false); + it("should not return Lock when provided user has no unlock methods", async () => { + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false)); + pinStateService.pinSet$.mockReturnValue(of(false)); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false)); - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId), + ); - expect(result).not.toContain(VaultTimeoutAction.Lock); - }); - - it("should return only LogOut when userId is not provided and there is no active account", async () => { - // Set up accountService to return null for activeAccount - accountService.activeAccount$ = of(null); - pinStateService.isPinSet.mockResolvedValue(false); - biometricStateService.biometricUnlockEnabled$ = of(false); - - // Call availableVaultTimeoutActions$ which internally calls userHasMasterPassword without a userId - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); - - // Since there's no active account, userHasMasterPassword returns false, - // meaning no master password is available, so Lock should not be available - expect(result).toEqual([VaultTimeoutAction.LogOut]); - expect(result).not.toContain(VaultTimeoutAction.Lock); + expect(result).not.toContain(VaultTimeoutAction.Lock); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); }); }); @@ -237,8 +287,8 @@ describe("VaultTimeoutSettingsService", () => { `( "returns $expected when policy is $policy, has PIN unlock method: $hasPinUnlock or Biometric unlock method: $hasBiometricUnlock, and user preference is $userPreference", async ({ hasPinUnlock, hasBiometricUnlock, policy, userPreference, expected }) => { - biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(hasBiometricUnlock); - pinStateService.isPinSet.mockResolvedValue(hasPinUnlock); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(hasBiometricUnlock)); + pinStateService.pinSet$.mockReturnValue(of(hasPinUnlock)); userDecryptionOptionsSubject.next( new UserDecryptionOptions({ hasMasterPassword: false }), diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts index 57e484fd767..5384d6860b7 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts @@ -3,16 +3,15 @@ import { catchError, combineLatest, - defer, distinctUntilChanged, EMPTY, firstValueFrom, from, map, + of, Observable, shareReplay, switchMap, - tap, concatMap, } from "rxjs"; @@ -28,6 +27,7 @@ import { PolicyType } from "../../../admin-console/enums"; import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service"; import { AccountService } from "../../../auth/abstractions/account.service"; import { TokenService } from "../../../auth/abstractions/token.service"; +import { getUserId } from "../../../auth/services/account.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; @@ -101,8 +101,29 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA await this.keyService.refreshAdditionalKeys(userId); } - availableVaultTimeoutActions$(userId?: string): Observable { - return defer(() => this.getAvailableVaultTimeoutActions(userId)); + availableVaultTimeoutActions$(userId?: UserId): Observable { + const userId$ = + userId != null + ? of(userId) + : // TODO remove with https://bitwarden.atlassian.net/browse/PM-10647 + getUserId(this.accountService.activeAccount$); + + return userId$.pipe( + switchMap((userId) => + combineLatest([ + this.userDecryptionOptionsService.hasMasterPasswordById$(userId), + this.biometricStateService.biometricUnlockEnabled$(userId), + this.pinStateService.pinSet$(userId), + ]), + ), + map(([haveMasterPassword, biometricUnlockEnabled, isPinSet]) => { + const canLock = haveMasterPassword || biometricUnlockEnabled || isPinSet; + if (canLock) { + return [VaultTimeoutAction.LogOut, VaultTimeoutAction.Lock]; + } + return [VaultTimeoutAction.LogOut]; + }), + ); } async canLock(userId: UserId): Promise { @@ -112,12 +133,8 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return availableVaultTimeoutActions?.includes(VaultTimeoutAction.Lock) || false; } - async isBiometricLockSet(userId?: string): Promise { - const biometricUnlockPromise = - userId == null - ? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$) - : this.biometricStateService.getBiometricUnlockEnabled(userId as UserId); - return await biometricUnlockPromise; + async isBiometricLockSet(userId?: UserId): Promise { + return await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$(userId)); } private async setVaultTimeout(userId: UserId, timeout: VaultTimeout): Promise { @@ -262,45 +279,45 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return combineLatest([ this.stateProvider.getUserState$(VAULT_TIMEOUT_ACTION, userId), this.getMaxSessionTimeoutPolicyDataByUserId$(userId), + this.availableVaultTimeoutActions$(userId), ]).pipe( - switchMap(([currentVaultTimeoutAction, maxSessionTimeoutPolicyData]) => { - return from( - this.determineVaultTimeoutAction( - userId, + concatMap( + async ([ + currentVaultTimeoutAction, + maxSessionTimeoutPolicyData, + availableVaultTimeoutActions, + ]) => { + const vaultTimeoutAction = this.determineVaultTimeoutAction( + availableVaultTimeoutActions, currentVaultTimeoutAction, maxSessionTimeoutPolicyData, - ), - ).pipe( - tap((vaultTimeoutAction: VaultTimeoutAction) => { - // As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current - // We want to avoid having a null timeout action always so we set it to the default if it is null - // and if the user becomes subject to a policy that requires a specific action, we set it to that - if (vaultTimeoutAction !== currentVaultTimeoutAction) { - return this.stateProvider.setUserState( - VAULT_TIMEOUT_ACTION, - vaultTimeoutAction, - userId, - ); - } - }), - catchError((error: unknown) => { - // Protect outer observable from canceling on error by catching and returning EMPTY - this.logService.error(`Error getting vault timeout: ${error}`); - return EMPTY; - }), - ); + ); + + // As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current + // We want to avoid having a null timeout action always so we set it to the default if it is null + // and if the user becomes subject to a policy that requires a specific action, we set it to that + if (vaultTimeoutAction !== currentVaultTimeoutAction) { + await this.stateProvider.setUserState(VAULT_TIMEOUT_ACTION, vaultTimeoutAction, userId); + } + + return vaultTimeoutAction; + }, + ), + catchError((error: unknown) => { + // Protect outer observable from canceling on error by catching and returning EMPTY + this.logService.error(`Error getting vault timeout: ${error}`); + return EMPTY; }), distinctUntilChanged(), // Avoid having the set side effect trigger a new emission of the same action shareReplay({ refCount: true, bufferSize: 1 }), ); } - private async determineVaultTimeoutAction( - userId: string, + private determineVaultTimeoutAction( + availableVaultTimeoutActions: VaultTimeoutAction[], currentVaultTimeoutAction: VaultTimeoutAction | null, maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, - ): Promise { - const availableVaultTimeoutActions = await this.getAvailableVaultTimeoutActions(userId); + ): VaultTimeoutAction { if (availableVaultTimeoutActions.length === 1) { return availableVaultTimeoutActions[0]; } @@ -339,38 +356,4 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null), ); } - - private async getAvailableVaultTimeoutActions(userId?: string): Promise { - userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; - - const availableActions = [VaultTimeoutAction.LogOut]; - - const canLock = - (await this.userHasMasterPassword(userId)) || - (await this.pinStateService.isPinSet(userId as UserId)) || - (await this.isBiometricLockSet(userId)); - - if (canLock) { - availableActions.push(VaultTimeoutAction.Lock); - } - - return availableActions; - } - - private async userHasMasterPassword(userId: string): Promise { - let resolvedUserId: UserId; - if (userId) { - resolvedUserId = userId as UserId; - } else { - const activeAccount = await firstValueFrom(this.accountService.activeAccount$); - if (!activeAccount) { - return false; // No account, can't have master password - } - resolvedUserId = activeAccount.id; - } - - return await firstValueFrom( - this.userDecryptionOptionsService.hasMasterPasswordById$(resolvedUserId), - ); - } } diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index 7eeb15f3ebe..4081eba2878 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -11,7 +11,6 @@ export class SendData { id: string; accessId: string; type: SendType; - authType: AuthType; name: string; notes: string; file: SendFileData; @@ -24,8 +23,10 @@ export class SendData { deletionDate: string; password: string; emails: string; + emailHashes: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(response?: SendResponse) { if (response == null) { @@ -46,8 +47,10 @@ export class SendData { this.deletionDate = response.deletionDate; this.password = response.password; this.emails = response.emails; + this.emailHashes = ""; this.disabled = response.disable; this.hideEmail = response.hideEmail; + this.authType = response.authType; switch (this.type) { case SendType.Text: diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index cd51390908e..f660333c917 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { emptyGuid, UserId } from "@bitwarden/common/types/guid"; // 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 @@ -15,7 +16,6 @@ import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; -import { Send } from "./send"; import { SendText } from "./send-text"; describe("Send", () => { @@ -26,7 +26,6 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, - authType: AuthType.None, name: "encName", notes: "encNotes", text: { @@ -41,9 +40,11 @@ describe("Send", () => { expirationDate: "2022-01-31T12:00:00.000Z", deletionDate: "2022-01-31T12:00:00.000Z", password: "password", - emails: null!, + emails: "", + emailHashes: "", disabled: false, hideEmail: true, + authType: AuthType.None, }; mockContainerService(); @@ -69,6 +70,8 @@ describe("Send", () => { expirationDate: null, deletionDate: null, password: undefined, + emails: null, + emailHashes: undefined, disabled: undefined, hideEmail: undefined, }); @@ -81,7 +84,6 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, - authType: AuthType.None, name: { encryptedString: "encName", encryptionType: 0 }, notes: { encryptedString: "encNotes", encryptionType: 0 }, text: { @@ -95,9 +97,11 @@ describe("Send", () => { expirationDate: new Date("2022-01-31T12:00:00.000Z"), deletionDate: new Date("2022-01-31T12:00:00.000Z"), password: "password", - emails: null!, + emails: null, + emailHashes: "", disabled: false, hideEmail: true, + authType: AuthType.None, }); }); @@ -121,14 +125,22 @@ describe("Send", () => { send.expirationDate = new Date("2022-01-31T12:00:00.000Z"); send.deletionDate = new Date("2022-01-31T12:00:00.000Z"); send.password = "password"; + send.emails = null; send.disabled = false; send.hideEmail = true; + send.authType = AuthType.None; const encryptService = mock(); const keyService = mock(); encryptService.decryptBytes .calledWith(send.key, userKey) .mockResolvedValue(makeStaticByteArray(32)); + encryptService.decryptString + .calledWith(send.name, "cryptoKey" as any) + .mockResolvedValue("name"); + encryptService.decryptString + .calledWith(send.notes, "cryptoKey" as any) + .mockResolvedValue("notes"); keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); keyService.userKey$.calledWith(userId).mockReturnValue(of(userKey)); @@ -137,12 +149,6 @@ describe("Send", () => { const view = await send.decrypt(userId); expect(text.decrypt).toHaveBeenNthCalledWith(1, "cryptoKey"); - expect(send.name.decrypt).toHaveBeenNthCalledWith( - 1, - null, - "cryptoKey", - "Property: name; ObjectContext: No Domain Context", - ); expect(view).toMatchObject({ id: "id", @@ -150,7 +156,6 @@ describe("Send", () => { name: "name", notes: "notes", type: 0, - authType: 2, key: expect.anything(), cryptoKey: "cryptoKey", file: expect.anything(), @@ -161,8 +166,265 @@ describe("Send", () => { expirationDate: new Date("2022-01-31T12:00:00.000Z"), deletionDate: new Date("2022-01-31T12:00:00.000Z"), password: "password", + emails: [], disabled: false, hideEmail: true, + authType: AuthType.None, + }); + }); + + describe("Email decryption", () => { + let encryptService: jest.Mocked; + let keyService: jest.Mocked; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const userId = emptyGuid as UserId; + + beforeEach(() => { + encryptService = mock(); + keyService = mock(); + encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32)); + keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); + keyService.userKey$.mockReturnValue(of(userKey)); + (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + }); + + it("should decrypt and parse single email", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc("test@example.com"); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve("test@example.com"); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(encryptService.decryptString).toHaveBeenCalledWith(send.emails, "cryptoKey"); + expect(view.emails).toEqual(["test@example.com"]); + }); + + it("should decrypt and parse multiple emails", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc("test@example.com,user@test.com,admin@domain.com"); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve("test@example.com,user@test.com,admin@domain.com"); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual(["test@example.com", "user@test.com", "admin@domain.com"]); + }); + + it("should trim whitespace from decrypted emails", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc(" test@example.com , user@test.com "); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve(" test@example.com , user@test.com "); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual(["test@example.com", "user@test.com"]); + }); + + it("should return empty array when emails is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual([]); + expect(encryptService.decryptString).not.toHaveBeenCalledWith(expect.anything(), "cryptoKey"); + }); + + it("should return empty array when decrypted emails is empty string", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc(""); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve(""); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual([]); + }); + + it("should return empty array when decrypted emails is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc("something"); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve(null); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual([]); + }); + }); + + describe("Null handling for name and notes decryption", () => { + let encryptService: jest.Mocked; + let keyService: jest.Mocked; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const userId = emptyGuid as UserId; + + beforeEach(() => { + encryptService = mock(); + keyService = mock(); + encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32)); + keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); + keyService.userKey$.mockReturnValue(of(userKey)); + (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + }); + + it("should return null for name when name is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = null; + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + const view = await send.decrypt(userId); + + expect(view.name).toBeNull(); + expect(encryptService.decryptString).not.toHaveBeenCalledWith(null, expect.anything()); + }); + + it("should return null for notes when notes is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = null; + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + const view = await send.decrypt(userId); + + expect(view.notes).toBeNull(); + }); + + it("should decrypt non-null name and notes", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("Test Name"); + send.notes = mockEnc("Test Notes"); + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.name) { + return Promise.resolve("Test Name"); + } + if (encString === send.notes) { + return Promise.resolve("Test Notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.name).toBe("Test Name"); + expect(view.notes).toBe("Test Notes"); }); }); }); diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index 82c37a17528..5247d35c655 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -20,7 +20,6 @@ export class Send extends Domain { id: string; accessId: string; type: SendType; - authType: AuthType; name: EncString; notes: EncString; file: SendFile; @@ -32,9 +31,11 @@ export class Send extends Domain { expirationDate: Date; deletionDate: Date; password: string; - emails: string; + emails: EncString; + emailHashes: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(obj?: SendData) { super(); @@ -51,6 +52,7 @@ export class Send extends Domain { name: null, notes: null, key: null, + emails: null, }, ["id", "accessId"], ); @@ -60,12 +62,13 @@ export class Send extends Domain { this.maxAccessCount = obj.maxAccessCount; this.accessCount = obj.accessCount; this.password = obj.password; - this.emails = obj.emails; + this.emailHashes = obj.emailHashes; this.disabled = obj.disabled; this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null; this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null; this.expirationDate = obj.expirationDate != null ? new Date(obj.expirationDate) : null; this.hideEmail = obj.hideEmail; + this.authType = obj.authType; switch (this.type) { case SendType.Text: @@ -91,8 +94,17 @@ export class Send extends Domain { // model.key is a seed used to derive a key, not a SymmetricCryptoKey model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey); model.cryptoKey = await keyService.makeSendKey(model.key); + model.name = + this.name != null ? await encryptService.decryptString(this.name, model.cryptoKey) : null; + model.notes = + this.notes != null ? await encryptService.decryptString(this.notes, model.cryptoKey) : null; - await this.decryptObj(this, model, ["name", "notes"], model.cryptoKey); + if (this.emails != null) { + const decryptedEmails = await encryptService.decryptString(this.emails, model.cryptoKey); + model.emails = decryptedEmails ? decryptedEmails.split(",").map((e) => e.trim()) : []; + } else { + model.emails = []; + } switch (this.type) { case SendType.File: @@ -121,6 +133,7 @@ export class Send extends Domain { key: EncString.fromJSON(obj.key), name: EncString.fromJSON(obj.name), notes: EncString.fromJSON(obj.notes), + emails: EncString.fromJSON(obj.emails), text: SendText.fromJSON(obj.text), file: SendFile.fromJSON(obj.file), revisionDate, diff --git a/libs/common/src/tools/send/models/request/send.request.spec.ts b/libs/common/src/tools/send/models/request/send.request.spec.ts new file mode 100644 index 00000000000..1daee1d01ff --- /dev/null +++ b/libs/common/src/tools/send/models/request/send.request.spec.ts @@ -0,0 +1,192 @@ +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; + +import { EncString } from "../../../../key-management/crypto/models/enc-string"; +import { SendType } from "../../types/send-type"; +import { SendText } from "../domain/send-text"; + +import { SendRequest } from "./send.request"; + +describe("SendRequest", () => { + describe("constructor", () => { + it("should populate emails with encrypted string from Send.emails", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("encryptedEmailList"); + send.emailHashes = "HASH1,HASH2,HASH3"; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emails).toBe("encryptedEmailList"); + }); + + it("should populate emailHashes from Send.emailHashes", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("encryptedEmailList"); + send.emailHashes = "HASH1,HASH2,HASH3"; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emailHashes).toBe("HASH1,HASH2,HASH3"); + }); + + it("should set emails to null when Send.emails is null", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emails).toBeNull(); + expect(request.emailHashes).toBe(""); + }); + + it("should handle empty emailHashes", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emailHashes).toBe(""); + }); + + it("should not expose plaintext emails", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("2.encrypted|emaildata|here"); + send.emailHashes = "ABC123,DEF456"; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + // Ensure the request contains the encrypted string format, not plaintext + expect(request.emails).toBe("2.encrypted|emaildata|here"); + expect(request.emails).not.toContain("@"); + }); + + it("should handle name being null", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = null; + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.name).toBeNull(); + }); + + it("should handle notes being null", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = null; + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.notes).toBeNull(); + }); + + it("should include fileLength when provided for text send", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send, 1024); + + expect(request.fileLength).toBe(1024); + }); + }); + + describe("Email auth requirements", () => { + it("should create request with encrypted emails and plaintext emailHashes", () => { + // Setup: A Send with encrypted emails and computed hashes + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("2.encryptedEmailString|data"); + send.emailHashes = "A1B2C3D4,E5F6G7H8"; // Plaintext hashes + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + // Act: Create the request + const request = new SendRequest(send); + + // emails field contains encrypted value + expect(request.emails).toBe("2.encryptedEmailString|data"); + expect(request.emails).toContain("encrypted"); + + //emailHashes field contains plaintext comma-separated hashes + expect(request.emailHashes).toBe("A1B2C3D4,E5F6G7H8"); + expect(request.emailHashes).not.toContain("encrypted"); + expect(request.emailHashes.split(",")).toHaveLength(2); + }); + }); +}); diff --git a/libs/common/src/tools/send/models/request/send.request.ts b/libs/common/src/tools/send/models/request/send.request.ts index 902ca0a2c54..37590e40108 100644 --- a/libs/common/src/tools/send/models/request/send.request.ts +++ b/libs/common/src/tools/send/models/request/send.request.ts @@ -18,6 +18,7 @@ export class SendRequest { file: SendFileApi; password: string; emails: string; + emailHashes: string; disabled: boolean; hideEmail: boolean; @@ -31,7 +32,8 @@ export class SendRequest { this.deletionDate = send.deletionDate != null ? send.deletionDate.toISOString() : null; this.key = send.key != null ? send.key.encryptedString : null; this.password = send.password; - this.emails = send.emails; + this.emails = send.emails ? send.emails.encryptedString : null; + this.emailHashes = send.emailHashes; this.disabled = send.disabled; this.hideEmail = send.hideEmail; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index 7a7885d5ae1..a51b1e8ac7a 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -1,8 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; + import { BaseResponse } from "../../../../models/response/base.response"; -import { AuthType } from "../../types/auth-type"; -import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; @@ -10,7 +11,6 @@ export class SendResponse extends BaseResponse { id: string; accessId: string; type: SendType; - authType: AuthType; name: string; notes: string; file: SendFileApi; @@ -25,6 +25,7 @@ export class SendResponse extends BaseResponse { emails: string; disable: boolean; hideEmail: boolean; + authType: AuthType; constructor(response: any) { super(response); @@ -44,6 +45,7 @@ export class SendResponse extends BaseResponse { this.emails = this.getResponseProperty("Emails"); this.disable = this.getResponseProperty("Disabled") || false; this.hideEmail = this.getResponseProperty("HideEmail") || false; + this.authType = this.getResponseProperty("AuthType"); const text = this.getResponseProperty("Text"); if (text != null) { diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index d07de6d8293..150a649671b 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -19,7 +19,6 @@ export class SendView implements View { key: Uint8Array; cryptoKey: SymmetricCryptoKey; type: SendType = null; - authType: AuthType = null; text = new SendTextView(); file = new SendFileView(); maxAccessCount?: number = null; @@ -31,6 +30,7 @@ export class SendView implements View { emails: string[] = []; disabled = false; hideEmail = false; + authType: AuthType = null; constructor(s?: Send) { if (!s) { @@ -49,6 +49,7 @@ export class SendView implements View { this.disabled = s.disabled; this.password = s.password; this.hideEmail = s.hideEmail; + this.authType = s.authType; } get urlB64Key(): string { diff --git a/libs/common/src/tools/send/services/send-api.service.abstraction.ts b/libs/common/src/tools/send/services/send-api.service.abstraction.ts index 80c4410af11..a7e36d8c8b1 100644 --- a/libs/common/src/tools/send/services/send-api.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send-api.service.abstraction.ts @@ -1,3 +1,5 @@ +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; + import { ListResponse } from "../../../models/response/list.response"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { Send } from "../models/domain/send"; @@ -16,6 +18,10 @@ export abstract class SendApiService { request: SendAccessRequest, apiUrl?: string, ): Promise; + abstract postSendAccessV2( + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise; abstract getSends(): Promise>; abstract postSend(request: SendRequest): Promise; abstract postFileTypeSend(request: SendRequest): Promise; @@ -28,6 +34,11 @@ export abstract class SendApiService { request: SendAccessRequest, apiUrl?: string, ): Promise; + abstract getSendFileDownloadDataV2( + send: SendAccessView, + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise; abstract renewSendFileUploadUrl( sendId: string, fileId: string, diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts index 1c931b7ad98..57004b6ff0e 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -1,3 +1,5 @@ +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; + import { ApiService } from "../../../abstractions/api.service"; import { ErrorResponse } from "../../../models/response/error.response"; import { ListResponse } from "../../../models/response/list.response"; @@ -52,6 +54,25 @@ export class SendApiService implements SendApiServiceAbstraction { return new SendAccessResponse(r); } + async postSendAccessV2( + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise { + const setAuthTokenHeader = (headers: Headers) => { + headers.set("Authorization", "Bearer " + accessToken.token); + }; + const r = await this.apiService.send( + "POST", + "/sends/access", + null, + false, + true, + apiUrl, + setAuthTokenHeader, + ); + return new SendAccessResponse(r); + } + async getSendFileDownloadData( send: SendAccessView, request: SendAccessRequest, @@ -72,6 +93,26 @@ export class SendApiService implements SendApiServiceAbstraction { return new SendFileDownloadDataResponse(r); } + async getSendFileDownloadDataV2( + send: SendAccessView, + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise { + const setAuthTokenHeader = (headers: Headers) => { + headers.set("Authorization", "Bearer " + accessToken.token); + }; + const r = await this.apiService.send( + "POST", + "/sends/access/file/" + send.file.id, + null, + true, + true, + apiUrl, + setAuthTokenHeader, + ); + return new SendFileDownloadDataResponse(r); + } + async getSends(): Promise> { const r = await this.apiService.send("GET", "/sends", null, true, true); return new ListResponse(r, SendResponse); @@ -148,6 +189,7 @@ export class SendApiService implements SendApiServiceAbstraction { private async upload(sendData: [Send, EncArrayBuffer]): Promise { const request = new SendRequest(sendData[0], sendData[1]?.buffer.byteLength); + let response: SendResponse; if (sendData[0].id == null) { if (sendData[0].type === SendType.Text) { diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index fb99ddbe3bc..1c587327098 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; // 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 { KeyService } from "@bitwarden/key-management"; @@ -16,6 +17,7 @@ import { import { KeyGenerationService } from "../../../key-management/crypto"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { EnvironmentService } from "../../../platform/abstractions/environment.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { Utils } from "../../../platform/misc/utils"; @@ -29,6 +31,7 @@ import { SendTextApi } from "../models/api/send-text.api"; import { SendFileData } from "../models/data/send-file.data"; import { SendTextData } from "../models/data/send-text.data"; import { SendData } from "../models/data/send.data"; +import { SendTextView } from "../models/view/send-text.view"; import { SendView } from "../models/view/send.view"; import { SendType } from "../types/send-type"; @@ -48,7 +51,8 @@ describe("SendService", () => { const keyGenerationService = mock(); const encryptService = mock(); const environmentService = mock(); - + const cryptoFunctionService = mock(); + const configService = mock(); let sendStateProvider: SendStateProvider; let sendService: SendService; @@ -94,6 +98,8 @@ describe("SendService", () => { keyGenerationService, sendStateProvider, encryptService, + cryptoFunctionService, + configService, ); }); @@ -573,4 +579,256 @@ describe("SendService", () => { expect(sendsAfterDelete.length).toBe(0); }); }); + + describe("encrypt", () => { + let sendView: SendView; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const mockCryptoKey = new SymmetricCryptoKey(new Uint8Array(32)); + + beforeEach(() => { + sendView = new SendView(); + sendView.id = "sendId"; + sendView.type = SendType.Text; + sendView.name = "Test Send"; + sendView.notes = "Test Notes"; + const sendTextView = new SendTextView(); + sendTextView.text = "test text"; + sendTextView.hidden = false; + sendView.text = sendTextView; + sendView.key = new Uint8Array(16); + sendView.cryptoKey = mockCryptoKey; + sendView.maxAccessCount = 5; + sendView.disabled = false; + sendView.hideEmail = false; + sendView.deletionDate = new Date("2024-12-31"); + sendView.expirationDate = null; + + keyService.userKey$.mockReturnValue(of(userKey)); + keyService.makeSendKey.mockResolvedValue(mockCryptoKey); + encryptService.encryptBytes.mockResolvedValue({ encryptedString: "encryptedKey" } as any); + encryptService.encryptString.mockResolvedValue({ encryptedString: "encrypted" } as any); + }); + + describe("when SendEmailOTP feature flag is ON", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(true); + cryptoFunctionService.hash.mockClear(); + }); + + describe("email encryption", () => { + it("should encrypt emails when email list is provided", async () => { + sendView.emails = ["test@example.com", "user@test.com"]; + cryptoFunctionService.hash.mockResolvedValue(new Uint8Array([0xab, 0xcd])); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(encryptService.encryptString).toHaveBeenCalledWith( + "test@example.com,user@test.com", + mockCryptoKey, + ); + expect(send.emails).toEqual({ encryptedString: "encrypted" }); + expect(send.password).toBeNull(); + }); + + it("should set emails to null when email list is empty", async () => { + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + }); + + it("should set emails to null when email list is null", async () => { + sendView.emails = null; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + }); + + it("should set emails to null when email list is undefined", async () => { + sendView.emails = undefined; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + }); + }); + + describe("email hashing", () => { + it("should hash emails using SHA-256 and return uppercase hex", async () => { + sendView.emails = ["test@example.com"]; + const mockHash = new Uint8Array([0xab, 0xcd, 0xef]); + + cryptoFunctionService.hash.mockResolvedValue(mockHash); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256"); + expect(send.emailHashes).toBe("ABCDEF"); + }); + + it("should hash multiple emails and return comma-separated hashes", async () => { + sendView.emails = ["test@example.com", "user@test.com"]; + const mockHash1 = new Uint8Array([0xab, 0xcd]); + const mockHash2 = new Uint8Array([0x12, 0x34]); + + cryptoFunctionService.hash + .mockResolvedValueOnce(mockHash1) + .mockResolvedValueOnce(mockHash2); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256"); + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256"); + expect(send.emailHashes).toBe("ABCD,1234"); + }); + + it("should trim and lowercase emails before hashing", async () => { + sendView.emails = [" Test@Example.COM ", "USER@test.com"]; + const mockHash = new Uint8Array([0xff]); + + cryptoFunctionService.hash.mockResolvedValue(mockHash); + + await sendService.encrypt(sendView, null, null); + + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256"); + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256"); + }); + + it("should set emailHashes to empty string when no emails", async () => { + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emailHashes).toBe(""); + expect(cryptoFunctionService.hash).not.toHaveBeenCalled(); + }); + + it("should handle single email correctly", async () => { + sendView.emails = ["single@test.com"]; + const mockHash = new Uint8Array([0xa1, 0xb2, 0xc3]); + + cryptoFunctionService.hash.mockResolvedValue(mockHash); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emailHashes).toBe("A1B2C3"); + }); + }); + + describe("emails and password mutual exclusivity", () => { + it("should set password to null when emails are provided", async () => { + sendView.emails = ["test@example.com"]; + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeDefined(); + expect(send.password).toBeNull(); + }); + + it("should set password when no emails are provided", async () => { + sendView.emails = []; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue({ + keyB64: "hashedPassword", + } as any); + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeNull(); + expect(send.password).toBe("hashedPassword"); + }); + }); + }); + + describe("when SendEmailOTP feature flag is OFF", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(false); + cryptoFunctionService.hash.mockClear(); + }); + + it("should NOT encrypt emails even when provided", async () => { + sendView.emails = ["test@example.com"]; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(cryptoFunctionService.hash).not.toHaveBeenCalled(); + }); + + it("should use password when provided and flag is OFF", async () => { + sendView.emails = []; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue({ + keyB64: "hashedPassword", + } as any); + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(send.password).toBe("hashedPassword"); + }); + + it("should ignore emails and use password when both provided", async () => { + sendView.emails = ["test@example.com"]; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue({ + keyB64: "hashedPassword", + } as any); + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(send.password).toBe("hashedPassword"); + expect(cryptoFunctionService.hash).not.toHaveBeenCalled(); + }); + + it("should set emails and password to null when neither provided", async () => { + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(send.password).toBeUndefined(); + }); + }); + + describe("null handling for name and notes", () => { + it("should handle null name correctly", async () => { + sendView.name = null; + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.name).toBeNull(); + }); + + it("should handle null notes correctly", async () => { + sendView.notes = null; + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.notes).toBeNull(); + }); + + it("should encrypt non-null name and notes", async () => { + sendView.name = "Test Name"; + sendView.notes = "Test Notes"; + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(encryptService.encryptString).toHaveBeenCalledWith("Test Name", mockCryptoKey); + expect(encryptService.encryptString).toHaveBeenCalledWith("Test Notes", mockCryptoKey); + expect(send.name).toEqual({ encryptedString: "encrypted" }); + expect(send.notes).toEqual({ encryptedString: "encrypted" }); + }); + }); + }); }); diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index c274d90146e..078e94b2563 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -7,9 +7,12 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv // eslint-disable-next-line no-restricted-imports import { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management"; +import { FeatureFlag } from "../../../enums/feature-flag.enum"; import { KeyGenerationService } from "../../../key-management/crypto"; +import { CryptoFunctionService } from "../../../key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { Utils } from "../../../platform/misc/utils"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; @@ -51,6 +54,8 @@ export class SendService implements InternalSendServiceAbstraction { private keyGenerationService: KeyGenerationService, private stateProvider: SendStateProvider, private encryptService: EncryptService, + private cryptoFunctionService: CryptoFunctionService, + private configService: ConfigService, ) {} async encrypt( @@ -80,19 +85,30 @@ export class SendService implements InternalSendServiceAbstraction { model.cryptoKey = key.derivedKey; } + // Check feature flag for email OTP authentication + const sendEmailOTPEnabled = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + const hasEmails = (model.emails?.length ?? 0) > 0; - if (hasEmails) { - send.emails = model.emails.join(","); + + if (sendEmailOTPEnabled && hasEmails) { + const plaintextEmails = model.emails.join(","); + send.emails = await this.encryptService.encryptString(plaintextEmails, model.cryptoKey); + send.emailHashes = await this.hashEmails(plaintextEmails); send.password = null; - } else if (password != null) { - // Note: Despite being called key, the passwordKey is not used for encryption. - // It is used as a static proof that the client knows the password, and has the encryption key. - const passwordKey = await this.keyGenerationService.deriveKeyFromPassword( - password, - model.key, - new PBKDF2KdfConfig(SEND_KDF_ITERATIONS), - ); - send.password = passwordKey.keyB64; + } else { + send.emails = null; + send.emailHashes = ""; + + if (password != null) { + // Note: Despite being called key, the passwordKey is not used for encryption. + // It is used as a static proof that the client knows the password, and has the encryption key. + const passwordKey = await this.keyGenerationService.deriveKeyFromPassword( + password, + model.key, + new PBKDF2KdfConfig(SEND_KDF_ITERATIONS), + ); + send.password = passwordKey.keyB64; + } } const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; if (userKey == null) { @@ -100,10 +116,14 @@ export class SendService implements InternalSendServiceAbstraction { } // Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey send.key = await this.encryptService.encryptBytes(model.key, userKey); - // FIXME: model.name can be null. encryptString should not be called with null values. - send.name = await this.encryptService.encryptString(model.name, model.cryptoKey); - // FIXME: model.notes can be null. encryptString should not be called with null values. - send.notes = await this.encryptService.encryptString(model.notes, model.cryptoKey); + send.name = + model.name != null + ? await this.encryptService.encryptString(model.name, model.cryptoKey) + : null; + send.notes = + model.notes != null + ? await this.encryptService.encryptString(model.notes, model.cryptoKey) + : null; if (send.type === SendType.Text) { send.text = new SendText(); // FIXME: model.text.text can be null. encryptString should not be called with null values. @@ -127,6 +147,8 @@ export class SendService implements InternalSendServiceAbstraction { } } + send.authType = model.authType; + return [send, fileData]; } @@ -371,4 +393,19 @@ export class SendService implements InternalSendServiceAbstraction { decryptedSends.sort(Utils.getSortFunction(this.i18nService, "name")); return decryptedSends; } + + private async hashEmails(emails: string): Promise { + if (!emails) { + return ""; + } + + const emailArray = emails.split(",").map((e) => e.trim().toLowerCase()); + const hashPromises = emailArray.map(async (email) => { + const hash: Uint8Array = await this.cryptoFunctionService.hash(email, "sha256"); + return Utils.fromBufferToHex(hash).toUpperCase(); + }); + + const hashes = await Promise.all(hashPromises); + return hashes.join(","); + } } diff --git a/libs/common/src/tools/send/services/test-data/send-tests.data.ts b/libs/common/src/tools/send/services/test-data/send-tests.data.ts index c1d04ab2926..9c4e121edc0 100644 --- a/libs/common/src/tools/send/services/test-data/send-tests.data.ts +++ b/libs/common/src/tools/send/services/test-data/send-tests.data.ts @@ -20,6 +20,7 @@ export function testSendViewData(id: string, name: string) { data.deletionDate = null; data.notes = "Notes!!"; data.key = null; + data.emails = []; return data; } @@ -39,6 +40,8 @@ export function createSendData(value: Partial = {}) { expirationDate: "2024-09-04", deletionDate: "2024-09-04", password: "password", + emails: "", + emailHashes: "", disabled: false, hideEmail: false, }; @@ -62,6 +65,8 @@ export function testSendData(id: string, name: string) { data.deletionDate = null; data.notes = "Notes!!"; data.key = null; + data.emails = ""; + data.emailHashes = ""; return data; } @@ -77,5 +82,7 @@ export function testSend(id: string, name: string) { data.deletionDate = null; data.notes = new EncString("Notes!!"); data.key = null; + data.emails = null; + data.emailHashes = ""; return data; } diff --git a/libs/common/src/vault/abstractions/cipher-sdk.service.ts b/libs/common/src/vault/abstractions/cipher-sdk.service.ts index 1037bfc2b92..3101531eda6 100644 --- a/libs/common/src/vault/abstractions/cipher-sdk.service.ts +++ b/libs/common/src/vault/abstractions/cipher-sdk.service.ts @@ -1,4 +1,4 @@ -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; /** @@ -34,4 +34,76 @@ export abstract class CipherSdkService { originalCipherView?: CipherView, orgAdmin?: boolean, ): Promise; + + /** + * Deletes a cipher on the server using the SDK. + * + * @param id The cipher ID to delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @returns A promise that resolves when the cipher is deleted + */ + abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + + /** + * Deletes multiple ciphers on the server using the SDK. + * + * @param ids The cipher IDs to delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @param orgId The organization ID (required when asAdmin is true) + * @returns A promise that resolves when the ciphers are deleted + */ + abstract deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; + + /** + * Soft deletes a cipher on the server using the SDK. + * + * @param id The cipher ID to soft delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @returns A promise that resolves when the cipher is soft deleted + */ + abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + + /** + * Soft deletes multiple ciphers on the server using the SDK. + * + * @param ids The cipher IDs to soft delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @param orgId The organization ID (required when asAdmin is true) + * @returns A promise that resolves when the ciphers are soft deleted + */ + abstract softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; + + /** + * Restores a soft-deleted cipher on the server using the SDK. + * + * @param id The cipher ID to restore + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @returns A promise that resolves when the cipher is restored + */ + abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + + /** + * Restores multiple soft-deleted ciphers on the server using the SDK. + * + * @param ids The cipher IDs to restore + * @param userId The user ID to use for SDK client + * @param orgId The organization ID (determines whether to use admin API) + * @returns A promise that resolves when the ciphers are restored + */ + abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise; } diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 1db5f8d38a7..4b544b2a34e 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -230,8 +230,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider; abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise; abstract delete(id: string | string[], userId: UserId): Promise; - abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; - abstract deleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise; + abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + abstract deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; abstract deleteAttachment( id: string, revisionDate: string, @@ -247,14 +252,19 @@ export abstract class CipherService implements UserKeyRotationDataProvider number; - abstract softDelete(id: string | string[], userId: UserId): Promise; - abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; - abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise; + abstract softDelete(id: string | string[], userId: UserId): Promise; + abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + abstract softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; abstract restore( cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[], userId: UserId, - ): Promise; - abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + ): Promise; + abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise; abstract getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise; abstract setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId): Promise; @@ -275,7 +285,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider; /** - * Decrypts a cipher using either the SDK or the legacy method based on the feature flag. + * Decrypts a cipher using either the use-sdk-cipheroperationsSDK or the legacy method based on the feature flag. * @param cipher The cipher to decrypt. * @param userId The user ID to use for decryption. * @returns A promise that resolves to the decrypted cipher view. diff --git a/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts b/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts index 01b0011b7f7..7a91d97ec7f 100644 --- a/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts +++ b/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts @@ -16,11 +16,6 @@ export abstract class VaultSettingsService { * An observable monitoring the state of the show identities on the current tab. */ abstract showIdentitiesCurrentTab$: Observable; - /** - * An observable monitoring the state of the click items on the Vault view - * for Autofill suggestions. - */ - abstract clickItemsToAutofillVaultView$: Observable; /** * Saves the enable passkeys setting to disk. @@ -37,10 +32,4 @@ export abstract class VaultSettingsService { * @param value The new value for the show identities on tab page setting. */ abstract setShowIdentitiesCurrentTab(value: boolean): Promise; - /** - * Saves the click items on vault View for Autofill suggestions to disk. - * @param value The new value for the click items on vault View for - * Autofill suggestions setting. - */ - abstract setClickItemsToAutofillVaultView(value: boolean): Promise; } diff --git a/libs/common/src/vault/services/cipher-sdk.service.spec.ts b/libs/common/src/vault/services/cipher-sdk.service.spec.ts index bd3feb4619e..cb21ff28133 100644 --- a/libs/common/src/vault/services/cipher-sdk.service.spec.ts +++ b/libs/common/src/vault/services/cipher-sdk.service.spec.ts @@ -28,10 +28,22 @@ describe("DefaultCipherSdkService", () => { mockAdminSdk = { create: jest.fn(), edit: jest.fn(), + delete: jest.fn().mockResolvedValue(undefined), + delete_many: jest.fn().mockResolvedValue(undefined), + soft_delete: jest.fn().mockResolvedValue(undefined), + soft_delete_many: jest.fn().mockResolvedValue(undefined), + restore: jest.fn().mockResolvedValue(undefined), + restore_many: jest.fn().mockResolvedValue(undefined), }; mockCiphersSdk = { create: jest.fn(), edit: jest.fn(), + delete: jest.fn().mockResolvedValue(undefined), + delete_many: jest.fn().mockResolvedValue(undefined), + soft_delete: jest.fn().mockResolvedValue(undefined), + soft_delete_many: jest.fn().mockResolvedValue(undefined), + restore: jest.fn().mockResolvedValue(undefined), + restore_many: jest.fn().mockResolvedValue(undefined), admin: jest.fn().mockReturnValue(mockAdminSdk), }; mockVaultSdk = { @@ -243,4 +255,280 @@ describe("DefaultCipherSdkService", () => { ); }); }); + + describe("deleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should delete cipher using SDK when asAdmin is false", async () => { + await cipherSdkService.deleteWithServer(testCipherId, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.delete).toHaveBeenCalledWith(testCipherId); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should delete cipher using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.deleteWithServer(testCipherId, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.delete).toHaveBeenCalledWith(testCipherId); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.delete.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete cipher"), + ); + }); + }); + + describe("deleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should delete multiple ciphers using SDK when asAdmin is false", async () => { + await cipherSdkService.deleteManyWithServer(testCipherIds, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.delete_many).toHaveBeenCalledWith(testCipherIds); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should delete multiple ciphers using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.delete_many).toHaveBeenCalledWith(testCipherIds, orgId); + }); + + it("should throw error when asAdmin is true but orgId is missing", async () => { + await expect( + cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, undefined), + ).rejects.toThrow("Organization ID is required for admin delete."); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete multiple ciphers"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.delete_many.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete multiple ciphers"), + ); + }); + }); + + describe("softDeleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should soft delete cipher using SDK when asAdmin is false", async () => { + await cipherSdkService.softDeleteWithServer(testCipherId, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.soft_delete).toHaveBeenCalledWith(testCipherId); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should soft delete cipher using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.softDeleteWithServer(testCipherId, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.soft_delete).toHaveBeenCalledWith(testCipherId); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.soft_delete.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete cipher"), + ); + }); + }); + + describe("softDeleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should soft delete multiple ciphers using SDK when asAdmin is false", async () => { + await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should soft delete multiple ciphers using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds, orgId); + }); + + it("should throw error when asAdmin is true but orgId is missing", async () => { + await expect( + cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, undefined), + ).rejects.toThrow("Organization ID is required for admin soft delete."); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect( + cipherSdkService.softDeleteManyWithServer(testCipherIds, userId), + ).rejects.toThrow("SDK not available"); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete multiple ciphers"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.soft_delete_many.mockRejectedValue(new Error("SDK error")); + + await expect( + cipherSdkService.softDeleteManyWithServer(testCipherIds, userId), + ).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete multiple ciphers"), + ); + }); + }); + + describe("restoreWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should restore cipher using SDK when asAdmin is false", async () => { + await cipherSdkService.restoreWithServer(testCipherId, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.restore).toHaveBeenCalledWith(testCipherId); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should restore cipher using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.restoreWithServer(testCipherId, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.restore).toHaveBeenCalledWith(testCipherId); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.restore.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore cipher"), + ); + }); + }); + + describe("restoreManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should restore multiple ciphers using SDK when orgId is not provided", async () => { + await cipherSdkService.restoreManyWithServer(testCipherIds, userId); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.restore_many).toHaveBeenCalledWith(testCipherIds); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should restore multiple ciphers using SDK admin API when orgId is provided", async () => { + const orgIdString = orgId as string; + await cipherSdkService.restoreManyWithServer(testCipherIds, userId, orgIdString); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.restore_many).toHaveBeenCalledWith(testCipherIds, orgIdString); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore multiple ciphers"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.restore_many.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore multiple ciphers"), + ); + }); + }); }); diff --git a/libs/common/src/vault/services/cipher-sdk.service.ts b/libs/common/src/vault/services/cipher-sdk.service.ts index 06f5d3eb961..9757b3d2cc7 100644 --- a/libs/common/src/vault/services/cipher-sdk.service.ts +++ b/libs/common/src/vault/services/cipher-sdk.service.ts @@ -1,8 +1,8 @@ import { firstValueFrom, switchMap, catchError } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; -import { UserId } from "@bitwarden/common/types/guid"; +import { SdkService, asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; @@ -79,4 +79,185 @@ export class DefaultCipherSdkService implements CipherSdkService { ), ); } + + async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + await ref.value.vault().ciphers().admin().delete(asUuid(id)); + } else { + await ref.value.vault().ciphers().delete(asUuid(id)); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to delete cipher: ${error}`); + throw error; + }), + ), + ); + } + + async deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + if (orgId == null) { + throw new Error("Organization ID is required for admin delete."); + } + await ref.value + .vault() + .ciphers() + .admin() + .delete_many( + ids.map((id) => asUuid(id)), + asUuid(orgId), + ); + } else { + await ref.value + .vault() + .ciphers() + .delete_many(ids.map((id) => asUuid(id))); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to delete multiple ciphers: ${error}`); + throw error; + }), + ), + ); + } + + async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + await ref.value.vault().ciphers().admin().soft_delete(asUuid(id)); + } else { + await ref.value.vault().ciphers().soft_delete(asUuid(id)); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to soft delete cipher: ${error}`); + throw error; + }), + ), + ); + } + + async softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + if (orgId == null) { + throw new Error("Organization ID is required for admin soft delete."); + } + await ref.value + .vault() + .ciphers() + .admin() + .soft_delete_many( + ids.map((id) => asUuid(id)), + asUuid(orgId), + ); + } else { + await ref.value + .vault() + .ciphers() + .soft_delete_many(ids.map((id) => asUuid(id))); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to soft delete multiple ciphers: ${error}`); + throw error; + }), + ), + ); + } + + async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + await ref.value.vault().ciphers().admin().restore(asUuid(id)); + } else { + await ref.value.vault().ciphers().restore(asUuid(id)); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to restore cipher: ${error}`); + throw error; + }), + ), + ); + } + + async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + + // No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable + // The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore + if (orgId) { + await ref.value + .vault() + .ciphers() + .admin() + .restore_many( + ids.map((id) => asUuid(id)), + asUuid(orgId), + ); + } else { + await ref.value + .vault() + .ciphers() + .restore_many(ids.map((id) => asUuid(id))); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to restore multiple ciphers: ${error}`); + throw error; + }), + ), + ); + } } diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 4f98ba62a1c..28b1f064d89 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -117,6 +117,8 @@ describe("Cipher Service", () => { let cipherService: CipherService; let encryptionContext: EncryptionContext; + // BehaviorSubject for SDK feature flag - allows tests to change the value after service instantiation + let sdkCrudFeatureFlag$: BehaviorSubject; beforeEach(() => { encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES)); @@ -132,6 +134,10 @@ describe("Cipher Service", () => { (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + // Create BehaviorSubject for SDK feature flag - tests can update this to change behavior + sdkCrudFeatureFlag$ = new BehaviorSubject(false); + configService.getFeatureFlag$.mockReturnValue(sdkCrudFeatureFlag$.asObservable()); + cipherService = new CipherService( keyService, domainSettingsService, @@ -280,9 +286,7 @@ describe("Cipher Service", () => { }); it("should delegate to cipherSdkService when feature flag is enabled", async () => { - configService.getFeatureFlag - .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) - .mockResolvedValue(true); + sdkCrudFeatureFlag$.next(true); const cipherView = new CipherView(encryptionContext.cipher); const expectedResult = new CipherView(encryptionContext.cipher); @@ -315,9 +319,9 @@ describe("Cipher Service", () => { }); it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => { - configService.getFeatureFlag + configService.getFeatureFlag$ .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) - .mockResolvedValue(false); + .mockReturnValue(of(false)); const testCipher = new Cipher(cipherData); testCipher.organizationId = orgId; @@ -368,9 +372,7 @@ describe("Cipher Service", () => { }); it("should delegate to cipherSdkService when feature flag is enabled", async () => { - configService.getFeatureFlag - .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) - .mockResolvedValue(true); + sdkCrudFeatureFlag$.next(true); const testCipher = new Cipher(cipherData); const cipherView = new CipherView(testCipher); @@ -392,9 +394,7 @@ describe("Cipher Service", () => { }); it("should delegate to cipherSdkService with orgAdmin when feature flag is enabled", async () => { - configService.getFeatureFlag - .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) - .mockResolvedValue(true); + sdkCrudFeatureFlag$.next(true); const testCipher = new Cipher(cipherData); const cipherView = new CipherView(testCipher); @@ -1009,6 +1009,238 @@ describe("Cipher Service", () => { }); }); + describe("deleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should call apiService.deleteCipher when feature flag is disabled", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "deleteCipher").mockResolvedValue(undefined); + + await cipherService.deleteWithServer(testCipherId, userId); + + expect(apiSpy).toHaveBeenCalledWith(testCipherId); + }); + + it("should call apiService.deleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "deleteCipherAdmin").mockResolvedValue(undefined); + + await cipherService.deleteWithServer(testCipherId, userId, true); + + expect(apiSpy).toHaveBeenCalledWith(testCipherId); + }); + + it("should use SDK to delete cipher when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.deleteWithServer(testCipherId, userId, false); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + + it("should use SDK admin delete when feature flag is enabled and asAdmin is true", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.deleteWithServer(testCipherId, userId, true); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + }); + + describe("deleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should call apiService.deleteManyCiphers when feature flag is disabled", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "deleteManyCiphers").mockResolvedValue(undefined); + + await cipherService.deleteManyWithServer(testCipherIds, userId); + + expect(apiSpy).toHaveBeenCalled(); + }); + + it("should call apiService.deleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "deleteManyCiphersAdmin").mockResolvedValue(undefined); + + await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(apiSpy).toHaveBeenCalled(); + }); + + it("should use SDK to delete multiple ciphers when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteManyWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.deleteManyWithServer(testCipherIds, userId, false); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + + it("should use SDK admin delete many when feature flag is enabled and asAdmin is true", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteManyWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + }); + + describe("softDeleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should call apiService.putDeleteCipher when feature flag is disabled", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "putDeleteCipher").mockResolvedValue(undefined); + + await cipherService.softDeleteWithServer(testCipherId, userId); + + expect(apiSpy).toHaveBeenCalledWith(testCipherId); + }); + + it("should call apiService.putDeleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "putDeleteCipherAdmin").mockResolvedValue(undefined); + + await cipherService.softDeleteWithServer(testCipherId, userId, true); + + expect(apiSpy).toHaveBeenCalledWith(testCipherId); + }); + + it("should use SDK to soft delete cipher when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.softDeleteWithServer(testCipherId, userId, false); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + + it("should use SDK admin soft delete when feature flag is enabled and asAdmin is true", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.softDeleteWithServer(testCipherId, userId, true); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + }); + + describe("softDeleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should call apiService.putDeleteManyCiphers when feature flag is disabled", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "putDeleteManyCiphers").mockResolvedValue(undefined); + + await cipherService.softDeleteManyWithServer(testCipherIds, userId); + + expect(apiSpy).toHaveBeenCalled(); + }); + + it("should call apiService.putDeleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest + .spyOn(apiService, "putDeleteManyCiphersAdmin") + .mockResolvedValue(undefined); + + await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(apiSpy).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId }); + }); + + it("should use SDK to soft delete multiple ciphers when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteManyWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.softDeleteManyWithServer(testCipherIds, userId, false); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + + it("should use SDK admin soft delete many when feature flag is enabled and asAdmin is true", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteManyWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + }); + describe("replace (no upsert)", () => { // In order to set up initial state we need to manually update the encrypted state // which will result in an emission. All tests will have this baseline emission. diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 53d7666e304..81060870e8b 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -106,6 +106,13 @@ export class CipherService implements CipherServiceAbstraction { */ private clearCipherViewsForUser$: Subject = new Subject(); + /** + * Observable exposing the feature flag status for using the SDK for cipher CRUD operations. + */ + private readonly sdkCipherCrudEnabled$: Observable = this.configService.getFeatureFlag$( + FeatureFlag.PM27632_SdkCipherCrudOperations, + ); + constructor( private keyService: KeyService, private domainSettingsService: DomainSettingsService, @@ -909,9 +916,7 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, orgAdmin?: boolean, ): Promise { - const useSdk = await this.configService.getFeatureFlag( - FeatureFlag.PM27632_SdkCipherCrudOperations, - ); + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); if (useSdk) { return ( @@ -970,9 +975,7 @@ export class CipherService implements CipherServiceAbstraction { originalCipherView?: CipherView, orgAdmin?: boolean, ): Promise { - const useSdk = await this.configService.getFeatureFlag( - FeatureFlag.PM27632_SdkCipherCrudOperations, - ); + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); if (useSdk) { return await this.updateWithServerUsingSdk(cipherView, userId, originalCipherView, orgAdmin); @@ -1389,7 +1392,14 @@ export class CipherService implements CipherServiceAbstraction { await this.encryptedCiphersState(userId).update(() => ciphers); } - async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.deleteWithServer(id, userId, asAdmin); + await this.clearCache(userId); + return; + } + if (asAdmin) { await this.apiService.deleteCipherAdmin(id); } else { @@ -1399,7 +1409,19 @@ export class CipherService implements CipherServiceAbstraction { await this.delete(id, userId); } - async deleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise { + async deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.deleteManyWithServer(ids, userId, asAdmin, orgId); + await this.clearCache(userId); + return; + } + const request = new CipherBulkDeleteRequest(ids); if (asAdmin) { await this.apiService.deleteManyCiphersAdmin(request); @@ -1539,7 +1561,7 @@ export class CipherService implements CipherServiceAbstraction { }; } - async softDelete(id: string | string[], userId: UserId): Promise { + async softDelete(id: string | string[], userId: UserId): Promise { let ciphers = await firstValueFrom(this.ciphers$(userId)); if (ciphers == null) { return; @@ -1567,7 +1589,14 @@ export class CipherService implements CipherServiceAbstraction { }); } - async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.softDeleteWithServer(id, userId, asAdmin); + await this.clearCache(userId); + return; + } + if (asAdmin) { await this.apiService.putDeleteCipherAdmin(id); } else { @@ -1577,8 +1606,20 @@ export class CipherService implements CipherServiceAbstraction { await this.softDelete(id, userId); } - async softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise { - const request = new CipherBulkDeleteRequest(ids); + async softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.softDeleteManyWithServer(ids, userId, asAdmin, orgId); + await this.clearCache(userId); + return; + } + + const request = new CipherBulkDeleteRequest(ids, orgId); if (asAdmin) { await this.apiService.putDeleteManyCiphersAdmin(request); } else { @@ -1621,7 +1662,14 @@ export class CipherService implements CipherServiceAbstraction { }); } - async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise { + async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.restoreWithServer(id, userId, asAdmin); + await this.clearCache(userId); + return; + } + let response; if (asAdmin) { response = await this.apiService.putRestoreCipherAdmin(id); @@ -1637,6 +1685,13 @@ export class CipherService implements CipherServiceAbstraction { * The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore */ async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.restoreManyWithServer(ids, userId, orgId); + await this.clearCache(userId); + return; + } + let response; if (orgId) { diff --git a/libs/common/src/vault/services/key-state/vault-settings.state.ts b/libs/common/src/vault/services/key-state/vault-settings.state.ts index 35bb776cc96..21364bbbf8e 100644 --- a/libs/common/src/vault/services/key-state/vault-settings.state.ts +++ b/libs/common/src/vault/services/key-state/vault-settings.state.ts @@ -25,12 +25,3 @@ export const SHOW_IDENTITIES_CURRENT_TAB = new UserKeyDefinition( clearOn: [], // do not clear user settings }, ); - -export const CLICK_ITEMS_AUTOFILL_VAULT_VIEW = new UserKeyDefinition( - VAULT_SETTINGS_DISK, - "clickItemsToAutofillOnVaultView", - { - deserializer: (obj) => obj, - clearOn: [], // do not clear user settings - }, -); diff --git a/libs/common/src/vault/services/vault-settings/vault-settings.service.ts b/libs/common/src/vault/services/vault-settings/vault-settings.service.ts index dbdb3a58dcc..098ca5faf2c 100644 --- a/libs/common/src/vault/services/vault-settings/vault-settings.service.ts +++ b/libs/common/src/vault/services/vault-settings/vault-settings.service.ts @@ -1,4 +1,4 @@ -import { Observable, combineLatest, map, shareReplay } from "rxjs"; +import { Observable, combineLatest, map } from "rxjs"; import { ActiveUserState, GlobalState, StateProvider } from "../../../platform/state"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "../../abstractions/vault-settings/vault-settings.service"; @@ -7,7 +7,6 @@ import { SHOW_CARDS_CURRENT_TAB, SHOW_IDENTITIES_CURRENT_TAB, USER_ENABLE_PASSKEYS, - CLICK_ITEMS_AUTOFILL_VAULT_VIEW, } from "../key-state/vault-settings.state"; import { RestrictedItemTypesService } from "../restricted-item-types.service"; @@ -49,17 +48,6 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction { readonly showIdentitiesCurrentTab$: Observable = this.showIdentitiesCurrentTabState.state$.pipe(map((x) => x ?? true)); - private clickItemsToAutofillVaultViewState: ActiveUserState = - this.stateProvider.getActive(CLICK_ITEMS_AUTOFILL_VAULT_VIEW); - /** - * {@link VaultSettingsServiceAbstraction.clickItemsToAutofillVaultView$$} - */ - readonly clickItemsToAutofillVaultView$: Observable = - this.clickItemsToAutofillVaultViewState.state$.pipe( - map((x) => x ?? false), - shareReplay({ bufferSize: 1, refCount: false }), - ); - constructor( private stateProvider: StateProvider, private restrictedItemTypesService: RestrictedItemTypesService, @@ -79,13 +67,6 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction { await this.showIdentitiesCurrentTabState.update(() => value); } - /** - * {@link VaultSettingsServiceAbstraction.setClickItemsToAutofillVaultView} - */ - async setClickItemsToAutofillVaultView(value: boolean): Promise { - await this.clickItemsToAutofillVaultViewState.update(() => value); - } - /** * {@link VaultSettingsServiceAbstraction.setEnablePasskeys} */ diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 84140a8953a..b8f8851864b 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -3,7 +3,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; import { Subject, filter, of, switchMap, tap } from "rxjs"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Translation } from "../dialog"; @@ -27,7 +27,7 @@ export interface AnonLayoutWrapperData { /** * The icon to display on the page. Pass null to hide the icon. */ - pageIcon: Icon | null; + pageIcon: BitSvg | null; /** * Optional flag to either show the optional environment selector (false) or just a readonly hostname (true). */ @@ -57,7 +57,7 @@ export class AnonLayoutWrapperComponent implements OnInit { protected pageTitle?: string | null; protected pageSubtitle?: string | null; - protected pageIcon: Icon | null = null; + protected pageIcon: BitSvg | null = null; protected showReadonlyHostname?: boolean | null; protected maxWidth?: LandingContentMaxWidthType | null; protected hideCardWrapper?: boolean | null; diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index eded556cd53..953a5e769cf 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -11,15 +11,15 @@ import { import { RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; -import { BitwardenLogo, Icon } from "@bitwarden/assets/svg"; +import { BitwardenLogo, BitSvg } from "@bitwarden/assets/svg"; import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { IconModule } from "../icon"; import { LandingContentMaxWidthType } from "../landing-layout"; import { LandingLayoutModule } from "../landing-layout/landing-layout.module"; import { SharedModule } from "../shared"; +import { SvgModule } from "../svg"; import { TypographyModule } from "../typography"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -28,7 +28,7 @@ import { TypographyModule } from "../typography"; selector: "auth-anon-layout", templateUrl: "./anon-layout.component.html", imports: [ - IconModule, + SvgModule, CommonModule, TypographyModule, SharedModule, @@ -45,7 +45,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { readonly title = input(); readonly subtitle = input(); - readonly icon = model.required(); + readonly icon = model.required(); readonly showReadonlyHostname = input(false); readonly hideLogo = input(false); readonly hideFooter = input(false); diff --git a/libs/components/src/anon-layout/anon-layout.stories.ts b/libs/components/src/anon-layout/anon-layout.stories.ts index 01cdc04ad73..ed6df181c85 100644 --- a/libs/components/src/anon-layout/anon-layout.stories.ts +++ b/libs/components/src/anon-layout/anon-layout.stories.ts @@ -2,7 +2,7 @@ import { ActivatedRoute, RouterModule } from "@angular/router"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { BehaviorSubject, of } from "rxjs"; -import { Icon, LockIcon } from "@bitwarden/assets/svg"; +import { BitSvg, LockIcon } from "@bitwarden/assets/svg"; import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -23,7 +23,7 @@ type StoryArgs = AnonLayoutComponent & { contentLength: "normal" | "long" | "thin"; showSecondary: boolean; useDefaultIcon: boolean; - icon: Icon; + icon: BitSvg; includeHeaderActions: boolean; }; diff --git a/libs/components/src/callout/callout.stories.ts b/libs/components/src/callout/callout.stories.ts index c2185203034..ff1a8c16d5f 100644 --- a/libs/components/src/callout/callout.stories.ts +++ b/libs/components/src/callout/callout.stories.ts @@ -1,7 +1,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LinkModule, IconModule } from "@bitwarden/components"; +import { LinkModule, SvgModule } from "@bitwarden/components"; import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet"; import { I18nMockService } from "../utils/i18n-mock.service"; @@ -13,7 +13,7 @@ export default { component: CalloutComponent, decorators: [ moduleMetadata({ - imports: [LinkModule, IconModule], + imports: [LinkModule, SvgModule], providers: [ { provide: I18nService, diff --git a/libs/components/src/header/header.stories.ts b/libs/components/src/header/header.stories.ts index 620f39a5dc3..23c2bb2edb5 100644 --- a/libs/components/src/header/header.stories.ts +++ b/libs/components/src/header/header.stories.ts @@ -14,7 +14,7 @@ import { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, @@ -40,7 +40,7 @@ export default { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, diff --git a/libs/components/src/icon/icon.component.ts b/libs/components/src/icon/icon.component.ts index f57a3627383..c2dc468dc71 100644 --- a/libs/components/src/icon/icon.component.ts +++ b/libs/components/src/icon/icon.component.ts @@ -1,35 +1,30 @@ -import { Component, effect, input } from "@angular/core"; -import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; -import { Icon, isIcon } from "@bitwarden/assets/svg"; +import { BitwardenIcon } from "../shared/icon"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-icon", + standalone: true, host: { - "[attr.aria-hidden]": "!ariaLabel()", + "[class]": "classList()", + "[attr.aria-hidden]": "ariaLabel() ? null : true", "[attr.aria-label]": "ariaLabel()", - "[innerHtml]": "innerHtml", - class: "tw-max-h-full tw-flex tw-justify-center", }, template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BitIconComponent { - innerHtml: SafeHtml | null = null; - - readonly icon = input(); +export class IconComponent { + /** + * The Bitwarden icon name (e.g., "bwi-lock", "bwi-user") + */ + readonly name = input.required(); + /** + * Accessible label for the icon + */ readonly ariaLabel = input(); - constructor(private domSanitizer: DomSanitizer) { - effect(() => { - const icon = this.icon(); - if (!isIcon(icon)) { - return; - } - const svg = icon.svg; - this.innerHtml = this.domSanitizer.bypassSecurityTrustHtml(svg); - }); - } + protected readonly classList = computed(() => { + return ["bwi", this.name()].join(" "); + }); } diff --git a/libs/components/src/icon/icon.mdx b/libs/components/src/icon/icon.mdx index 4f6f13c895e..0914d681e59 100644 --- a/libs/components/src/icon/icon.mdx +++ b/libs/components/src/icon/icon.mdx @@ -8,113 +8,40 @@ import * as stories from "./icon.stories"; import { IconModule } from "@bitwarden/components"; ``` -# Icon Use Instructions +# Icon -- Icons will generally be attached to the associated Jira task. - - Designers should minify any SVGs before attaching them to Jira using a tool like - [SVGOMG](https://jakearchibald.github.io/svgomg/). - - **Note:** Ensure the "Remove viewbox" option is toggled off if responsive resizing of the icon - is desired. +The `bit-icon` component renders Bitwarden Web Icons (bwi) using icon font classes. -## Developer Instructions +## Basic Usage -1. **Download the SVG** and import it as an `.svg` initially into the IDE of your choice. - - The SVG should be formatted using either a built-in formatter or an external tool like - [SVG Formatter Beautifier](https://codebeautify.org/svg-formatter-beautifier) to make applying - classes easier. +```html + +``` -2. **Rename the file** as a `.icon.ts` TypeScript file and place it in the `libs/assets/svg` - lib. +## Icon Names -3. **Import** `svgIcon` from `./icon-service`. +All available icon names are defined in the `BitwardenIcon` type. Icons use the `bwi-*` naming +convention (e.g., `bwi-lock`, `bwi-user`, `bwi-key`). -4. **Define and export** a `const` to represent your `svgIcon`. +## Accessibility - ```typescript - export const ExampleIcon = svgIcon``; - ``` +By default, icons are decorative and marked with `aria-hidden="true"`. To make an icon accessible, +provide an `ariaLabel`: -5. **Replace any hardcoded strokes or fills** with the appropriate Tailwind class. - - **Note:** Stroke is used when styling the outline of an SVG path, while fill is used when - styling the inside of an SVG path. +```html + +``` - - A non-comprehensive list of common colors and their associated classes is below: +## Styling - | Hardcoded Value | Tailwind Stroke Class | Tailwind Fill Class | Tailwind Variable | - | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------- | ----------------------------------- | - | `#020F66` | `tw-stroke-illustration-outline` | `tw-fill-illustration-outline` | `--color-illustration-outline` | - | `#DBE5F6` | `tw-stroke-illustration-bg-primary` | `tw-fill-illustration-bg-primary` | `--color-illustration-bg-primary` | - | `#AAC3EF` | `tw-stroke-illustration-bg-secondary` | `tw-fill-illustration-bg-secondary` | `--color-illustration-bg-secondary` | - | `#FFFFFF` | `tw-stroke-illustration-bg-tertiary` | `tw-fill-illustration-bg-tertiary` | `--color-illustration-bg-tertiary` | - | `#FFBF00` | `tw-stroke-illustration-tertiary` | `tw-fill-illustration-tertiary` | `--color-illustration-tertiary` | - | `#175DDC` | `tw-stroke-illustration-logo` | `tw-fill-illustration-logo` | `--color-illustration-logo` | +The component renders as an inline element. Apply standard CSS classes or styles to customize +appearance: - - If the hex that you have on an SVG path is not listed above, there are a few ways to figure out - the appropriate Tailwind class: - - **Option 1: Figma** - - Open the SVG in Figma. - - Click on an individual path on the SVG until you see the path's properties in the - right-hand panel. - - Scroll down to the Colors section. - - Example: `Color/Illustration/Outline` - - This also includes Hex or RGB values that can be used to find the appropriate Tailwind - variable as well if you follow the manual search option below. - - Create the appropriate stroke or fill class from the color used. - - Example: `Color/Illustration/Outline` corresponds to `--color-illustration-outline` which - corresponds to `tw-stroke-illustration-outline` or `tw-fill-illustration-outline`. - - **Option 2: Manual Search** - - Take the path's stroke or fill hex value and convert it to RGB using a tool like - [Hex to RGB](https://www.rgbtohex.net/hex-to-rgb/). - - Search for the RGB value without commas in our `tw-theme.css` to find the Tailwind variable - that corresponds to the color. - - Create the appropriate stroke or fill class using the Tailwind variable. - - Example: `--color-illustration-outline` corresponds to `tw-stroke-illustration-outline` - or `tw-fill-illustration-outline`. +```html + +``` -6. **Remove any hardcoded width or height attributes** if your SVG has a configured - [viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) attribute in order - to allow the SVG to scale to fit its container. - - **Note:** Scaling is required for any SVG used as an - [AnonLayout](?path=/docs/component-library-anon-layout--docs) `pageIcon`. +## Note on SVG Icons -7. **Replace any generic `clipPath` ids** (such as `id="a"`) with a unique id, and update the - referencing element to use the new id (such as `clip-path="url(#unique-id-here)"`). - -8. **Import your SVG const** anywhere you want to use the SVG. - - **Angular Component Example:** - - **TypeScript:** - - ```typescript - import { Component } from "@angular/core"; - import { IconModule } from '@bitwarden/components'; - import { ExampleIcon, Example2Icon } from "@bitwarden/assets/svg"; - - @Component({ - selector: "app-example", - standalone: true, - imports: [IconModule], - templateUrl: "./example.component.html", - }) - export class ExampleComponent { - readonly Icons = { ExampleIcon, Example2Icon }; - ... - } - ``` - - - **HTML:** - - > NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an - > `ariaLabel` is explicitly provided to the `` component - - ```html - - ``` - - With `ariaLabel` - - ```html - - ``` - -9. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client - which supports multiple style modes. +For SVG illustrations (not font icons), use the `bit-svg` component instead. See the Svg component +documentation for details. diff --git a/libs/components/src/icon/icon.module.ts b/libs/components/src/icon/icon.module.ts index 3d15b5bb3c3..b3e65619bd3 100644 --- a/libs/components/src/icon/icon.module.ts +++ b/libs/components/src/icon/icon.module.ts @@ -1,9 +1,9 @@ import { NgModule } from "@angular/core"; -import { BitIconComponent } from "./icon.component"; +import { IconComponent } from "./icon.component"; @NgModule({ - imports: [BitIconComponent], - exports: [BitIconComponent], + imports: [IconComponent], + exports: [IconComponent], }) export class IconModule {} diff --git a/libs/components/src/icon/icon.stories.ts b/libs/components/src/icon/icon.stories.ts index 9830c6d808a..5626407ea51 100644 --- a/libs/components/src/icon/icon.stories.ts +++ b/libs/components/src/icon/icon.stories.ts @@ -1,50 +1,61 @@ -import { Meta } from "@storybook/angular"; +import { Meta, StoryObj } from "@storybook/angular"; -import * as SvgIcons from "@bitwarden/assets/svg"; +import { BITWARDEN_ICONS } from "../shared/icon"; -import { BitIconComponent } from "./icon.component"; +import { IconComponent } from "./icon.component"; export default { title: "Component Library/Icon", - component: BitIconComponent, + component: IconComponent, parameters: { design: { type: "figma", url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-50335&t=k6OTDDPZOTtypRqo-11", }, }, -} as Meta; + argTypes: { + name: { + control: { type: "select" }, + options: BITWARDEN_ICONS, + }, + }, +} as Meta; -const { - // Filtering out the few non-icons in the libs/assets/svg import +type Story = StoryObj; - DynamicContentNotAllowedError: _DynamicContentNotAllowedError, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isIcon, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - svgIcon, - ...Icons -}: { - [key: string]: any; -} = SvgIcons; - -export const Default = { - render: (args: { icons: [string, any][] }) => ({ - props: args, - template: /*html*/ ` -
    - @for (icon of icons; track icon[0]) { -
    -
    {{icon[0]}}
    -
    - -
    -
    - } -
    - `, - }), +export const Default: Story = { args: { - icons: Object.entries(Icons), + name: "bwi-lock", }, }; + +export const AllIcons: Story = { + render: () => ({ + template: ` +
    + @for (icon of icons; track icon) { +
    + + {{ icon }} +
    + } +
    + `, + props: { + icons: BITWARDEN_ICONS, + }, + }), +}; + +export const WithAriaLabel: Story = { + args: { + name: "bwi-lock", + ariaLabel: "Secure lock icon", + }, +}; + +export const CompareWithLegacy: Story = { + render: () => ({ + template: ` `, + }), +}; diff --git a/libs/components/src/icon/index.ts b/libs/components/src/icon/index.ts index 1ee66e59837..670966a7630 100644 --- a/libs/components/src/icon/index.ts +++ b/libs/components/src/icon/index.ts @@ -1 +1,2 @@ export * from "./icon.module"; +export * from "./icon.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 9c4dadadd4b..80fd6fc05a6 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -22,6 +22,7 @@ export * from "./form-field"; export * from "./header"; export * from "./icon-button"; export * from "./icon"; +export * from "./svg"; export * from "./icon-tile"; export * from "./input"; export * from "./item"; diff --git a/libs/components/src/landing-layout/landing-header.component.html b/libs/components/src/landing-layout/landing-header.component.html index ed6d34ef23b..882f1b96c99 100644 --- a/libs/components/src/landing-layout/landing-header.component.html +++ b/libs/components/src/landing-layout/landing-header.component.html @@ -4,7 +4,7 @@ [routerLink]="['/']" class="tw-w-32 tw-py-5 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top" > - + }
    diff --git a/libs/components/src/landing-layout/landing-header.component.ts b/libs/components/src/landing-layout/landing-header.component.ts index eb5329e915d..c0fb3cd67f1 100644 --- a/libs/components/src/landing-layout/landing-header.component.ts +++ b/libs/components/src/landing-layout/landing-header.component.ts @@ -3,8 +3,8 @@ import { RouterModule } from "@angular/router"; import { BitwardenLogo } from "@bitwarden/assets/svg"; -import { IconModule } from "../icon"; import { SharedModule } from "../shared"; +import { SvgModule } from "../svg"; /** * Header component for landing pages with optional Bitwarden logo and header actions slot. @@ -34,7 +34,7 @@ import { SharedModule } from "../shared"; selector: "bit-landing-header", changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./landing-header.component.html", - imports: [RouterModule, IconModule, SharedModule], + imports: [RouterModule, SvgModule, SharedModule], }) export class LandingHeaderComponent { readonly hideLogo = input(false); diff --git a/libs/components/src/landing-layout/landing-hero.component.html b/libs/components/src/landing-layout/landing-hero.component.html index dbce6a7c585..9394bb03c63 100644 --- a/libs/components/src/landing-layout/landing-hero.component.html +++ b/libs/components/src/landing-layout/landing-hero.component.html @@ -6,7 +6,7 @@
    - +
    } diff --git a/libs/components/src/landing-layout/landing-hero.component.ts b/libs/components/src/landing-layout/landing-hero.component.ts index b29e9768efd..d3b9ffd0ee9 100644 --- a/libs/components/src/landing-layout/landing-hero.component.ts +++ b/libs/components/src/landing-layout/landing-hero.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, input } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; -import { IconModule } from "../icon"; +import { SvgModule } from "../svg"; import { TypographyModule } from "../typography"; /** @@ -31,10 +31,10 @@ import { TypographyModule } from "../typography"; selector: "bit-landing-hero", changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./landing-hero.component.html", - imports: [IconModule, TypographyModule], + imports: [SvgModule, TypographyModule], }) export class LandingHeroComponent { - readonly icon = input(null); + readonly icon = input(null); readonly title = input(); readonly subtitle = input(); } diff --git a/libs/components/src/landing-layout/landing-layout.component.html b/libs/components/src/landing-layout/landing-layout.component.html index 1164f538116..a33054e8e64 100644 --- a/libs/components/src/landing-layout/landing-layout.component.html +++ b/libs/components/src/landing-layout/landing-layout.component.html @@ -13,12 +13,12 @@
    - +
    - +
    } diff --git a/libs/components/src/landing-layout/landing-layout.component.ts b/libs/components/src/landing-layout/landing-layout.component.ts index 520cca945d6..65c7302e828 100644 --- a/libs/components/src/landing-layout/landing-layout.component.ts +++ b/libs/components/src/landing-layout/landing-layout.component.ts @@ -3,7 +3,7 @@ import { Component, ChangeDetectionStrategy, inject, input } from "@angular/core import { BackgroundLeftIllustration, BackgroundRightIllustration } from "@bitwarden/assets/svg"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { IconModule } from "../icon"; +import { SvgModule } from "../svg"; /** * Root layout component for landing pages providing a full-screen container with optional decorative background illustrations. @@ -27,7 +27,7 @@ import { IconModule } from "../icon"; selector: "bit-landing-layout", changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./landing-layout.component.html", - imports: [IconModule], + imports: [SvgModule], }) export class LandingLayoutComponent { readonly hideBackgroundIllustration = input(false); diff --git a/libs/components/src/navigation/nav-logo.component.html b/libs/components/src/navigation/nav-logo.component.html index 9f18855ae13..8323a0f3479 100644 --- a/libs/components/src/navigation/nav-logo.component.html +++ b/libs/components/src/navigation/nav-logo.component.html @@ -16,6 +16,6 @@ routerLinkActive ariaCurrentWhenActive="page" > - +
    diff --git a/libs/components/src/navigation/nav-logo.component.ts b/libs/components/src/navigation/nav-logo.component.ts index fec50ee8902..4b3dc471edb 100644 --- a/libs/components/src/navigation/nav-logo.component.ts +++ b/libs/components/src/navigation/nav-logo.component.ts @@ -1,16 +1,16 @@ import { ChangeDetectionStrategy, Component, input, inject } from "@angular/core"; import { RouterLinkActive, RouterLink } from "@angular/router"; -import { BitwardenShield, Icon } from "@bitwarden/assets/svg"; +import { BitwardenShield, BitSvg } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "../icon/icon.component"; +import { SvgComponent } from "../svg/svg.component"; import { SideNavService } from "./side-nav.service"; @Component({ selector: "bit-nav-logo", templateUrl: "./nav-logo.component.html", - imports: [RouterLinkActive, RouterLink, BitIconComponent], + imports: [RouterLinkActive, RouterLink, SvgComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavLogoComponent { @@ -26,7 +26,7 @@ export class NavLogoComponent { /** * Icon that is displayed when the side nav is open */ - readonly openIcon = input.required(); + readonly openIcon = input.required(); /** * Route to be passed to internal `routerLink` diff --git a/libs/components/src/no-items/no-items.component.html b/libs/components/src/no-items/no-items.component.html index e728584a41a..46a5c25526a 100644 --- a/libs/components/src/no-items/no-items.component.html +++ b/libs/components/src/no-items/no-items.component.html @@ -1,7 +1,7 @@
    - +

    diff --git a/libs/components/src/no-items/no-items.component.ts b/libs/components/src/no-items/no-items.component.ts index c6e52a1f83d..d2cacfd2251 100644 --- a/libs/components/src/no-items/no-items.component.ts +++ b/libs/components/src/no-items/no-items.component.ts @@ -1,18 +1,17 @@ -import { Component, input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { NoResults } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "../icon/icon.component"; +import { SvgComponent } from "../svg/svg.component"; /** * Component for displaying a message when there are no items to display. Expects title, description and button slots. */ -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-no-items", templateUrl: "./no-items.component.html", - imports: [BitIconComponent], + imports: [SvgComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NoItemsComponent { readonly icon = input(NoResults); diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts index c4fe2f9b2af..398251fd2e2 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts @@ -16,7 +16,6 @@ import { DialogModule } from "../../dialog"; import { DrawerModule } from "../../drawer"; import { FormControlModule } from "../../form-control"; import { FormFieldModule } from "../../form-field"; -import { IconModule } from "../../icon"; import { IconButtonModule } from "../../icon-button"; import { InputModule } from "../../input"; import { LayoutComponent } from "../../layout"; @@ -31,6 +30,7 @@ import { SearchModule } from "../../search"; import { SectionComponent } from "../../section"; import { SelectModule } from "../../select"; import { SharedModule } from "../../shared"; +import { SvgModule } from "../../svg"; import { TableModule } from "../../table"; import { TabsModule } from "../../tabs"; import { ToggleGroupModule } from "../../toggle-group"; @@ -54,7 +54,7 @@ import { TypographyModule } from "../../typography"; FormFieldModule, FormsModule, IconButtonModule, - IconModule, + SvgModule, InputModule, LayoutComponent, LinkModule, @@ -92,7 +92,7 @@ import { TypographyModule } from "../../typography"; FormFieldModule, FormsModule, IconButtonModule, - IconModule, + SvgModule, InputModule, LayoutComponent, LinkModule, diff --git a/libs/components/src/svg/index.ts b/libs/components/src/svg/index.ts new file mode 100644 index 00000000000..ae4c480e786 --- /dev/null +++ b/libs/components/src/svg/index.ts @@ -0,0 +1,2 @@ +export * from "./svg.module"; +export * from "./svg.component"; diff --git a/libs/components/src/svg/svg.component.ts b/libs/components/src/svg/svg.component.ts new file mode 100644 index 00000000000..bcb63cfa568 --- /dev/null +++ b/libs/components/src/svg/svg.component.ts @@ -0,0 +1,31 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from "@angular/core"; +import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; + +import { BitSvg, isBitSvg } from "@bitwarden/assets/svg"; + +@Component({ + selector: "bit-svg", + host: { + "[attr.aria-hidden]": "!ariaLabel()", + "[attr.aria-label]": "ariaLabel()", + "[innerHtml]": "innerHtml()", + class: "tw-max-h-full tw-flex tw-justify-center", + }, + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SvgComponent { + private domSanitizer = inject(DomSanitizer); + + readonly content = input(); + readonly ariaLabel = input(); + + protected readonly innerHtml = computed(() => { + const content = this.content(); + if (!isBitSvg(content)) { + return null; + } + const svg = content.svg; + return this.domSanitizer.bypassSecurityTrustHtml(svg); + }); +} diff --git a/libs/components/src/icon/icon.components.spec.ts b/libs/components/src/svg/svg.components.spec.ts similarity index 55% rename from libs/components/src/icon/icon.components.spec.ts rename to libs/components/src/svg/svg.components.spec.ts index 3ae37ff5423..55874d29e6c 100644 --- a/libs/components/src/icon/icon.components.spec.ts +++ b/libs/components/src/svg/svg.components.spec.ts @@ -1,25 +1,25 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { Icon, svgIcon } from "@bitwarden/assets/svg"; +import { BitSvg, svg } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "./icon.component"; +import { SvgComponent } from "./svg.component"; -describe("IconComponent", () => { - let fixture: ComponentFixture; +describe("SvgComponent", () => { + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [BitIconComponent], + imports: [SvgComponent], }).compileComponents(); - fixture = TestBed.createComponent(BitIconComponent); + fixture = TestBed.createComponent(SvgComponent); fixture.detectChanges(); }); it("should have empty innerHtml when input is not an Icon", () => { - const fakeIcon = { svg: "harmful user input" } as Icon; + const fakeIcon = { svg: "harmful user input" } as BitSvg; - fixture.componentRef.setInput("icon", fakeIcon); + fixture.componentRef.setInput("content", fakeIcon); fixture.detectChanges(); const el = fixture.nativeElement as HTMLElement; @@ -27,9 +27,9 @@ describe("IconComponent", () => { }); it("should contain icon when input is a safe Icon", () => { - const icon = svgIcon`safe icon`; + const icon = svg`safe icon`; - fixture.componentRef.setInput("icon", icon); + fixture.componentRef.setInput("content", icon); fixture.detectChanges(); const el = fixture.nativeElement as HTMLElement; diff --git a/libs/components/src/svg/svg.mdx b/libs/components/src/svg/svg.mdx new file mode 100644 index 00000000000..a29a6f86b14 --- /dev/null +++ b/libs/components/src/svg/svg.mdx @@ -0,0 +1,120 @@ +import { Meta, Story, Controls } from "@storybook/addon-docs/blocks"; + +import * as stories from "./svg.stories"; + + + +```ts +import { SvgModule } from "@bitwarden/components"; +``` + +# Svg Use Instructions + +- Icons will generally be attached to the associated Jira task. + - Designers should minify any SVGs before attaching them to Jira using a tool like + [SVGOMG](https://jakearchibald.github.io/svgomg/). + - **Note:** Ensure the "Remove viewbox" option is toggled off if responsive resizing of the icon + is desired. + +## Developer Instructions + +1. **Download the SVG** and import it as an `.svg` initially into the IDE of your choice. + - The SVG should be formatted using either a built-in formatter or an external tool like + [SVG Formatter Beautifier](https://codebeautify.org/svg-formatter-beautifier) to make applying + classes easier. + +2. **Rename the file** as a `.icon.ts` TypeScript file and place it in the `libs/assets/svg` + lib. + +3. **Import** `svg` from `./svg`. + +4. **Define and export** a `const` to represent your `svg`. + + ```typescript + export const ExampleIcon = svg``; + ``` + +5. **Replace any hardcoded strokes or fills** with the appropriate Tailwind class. + - **Note:** Stroke is used when styling the outline of an SVG path, while fill is used when + styling the inside of an SVG path. + + - A non-comprehensive list of common colors and their associated classes is below: + + | Hardcoded Value | Tailwind Stroke Class | Tailwind Fill Class | Tailwind Variable | + | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------- | ----------------------------------- | + | `#020F66` | `tw-stroke-illustration-outline` | `tw-fill-illustration-outline` | `--color-illustration-outline` | + | `#DBE5F6` | `tw-stroke-illustration-bg-primary` | `tw-fill-illustration-bg-primary` | `--color-illustration-bg-primary` | + | `#AAC3EF` | `tw-stroke-illustration-bg-secondary` | `tw-fill-illustration-bg-secondary` | `--color-illustration-bg-secondary` | + | `#FFFFFF` | `tw-stroke-illustration-bg-tertiary` | `tw-fill-illustration-bg-tertiary` | `--color-illustration-bg-tertiary` | + | `#FFBF00` | `tw-stroke-illustration-tertiary` | `tw-fill-illustration-tertiary` | `--color-illustration-tertiary` | + | `#175DDC` | `tw-stroke-illustration-logo` | `tw-fill-illustration-logo` | `--color-illustration-logo` | + + - If the hex that you have on an SVG path is not listed above, there are a few ways to figure out + the appropriate Tailwind class: + - **Option 1: Figma** + - Open the SVG in Figma. + - Click on an individual path on the SVG until you see the path's properties in the + right-hand panel. + - Scroll down to the Colors section. + - Example: `Color/Illustration/Outline` + - This also includes Hex or RGB values that can be used to find the appropriate Tailwind + variable as well if you follow the manual search option below. + - Create the appropriate stroke or fill class from the color used. + - Example: `Color/Illustration/Outline` corresponds to `--color-illustration-outline` which + corresponds to `tw-stroke-illustration-outline` or `tw-fill-illustration-outline`. + - **Option 2: Manual Search** + - Take the path's stroke or fill hex value and convert it to RGB using a tool like + [Hex to RGB](https://www.rgbtohex.net/hex-to-rgb/). + - Search for the RGB value without commas in our `tw-theme.css` to find the Tailwind variable + that corresponds to the color. + - Create the appropriate stroke or fill class using the Tailwind variable. + - Example: `--color-illustration-outline` corresponds to `tw-stroke-illustration-outline` + or `tw-fill-illustration-outline`. + +6. **Remove any hardcoded width or height attributes** if your SVG has a configured + [viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) attribute in order + to allow the SVG to scale to fit its container. + - **Note:** Scaling is required for any SVG used as an + [AnonLayout](?path=/docs/component-library-anon-layout--docs) `pageIcon`. + +7. **Replace any generic `clipPath` ids** (such as `id="a"`) with a unique id, and update the + referencing element to use the new id (such as `clip-path="url(#unique-id-here)"`). + +8. **Import your SVG const** anywhere you want to use the SVG. + - **Angular Component Example:** + - **TypeScript:** + + ```typescript + import { Component } from "@angular/core"; + import { SvgModule } from '@bitwarden/components'; + import { ExampleIcon, Example2Icon } from "@bitwarden/assets/svg"; + + @Component({ + selector: "app-example", + standalone: true, + imports: [SvgModule], + templateUrl: "./example.component.html", + }) + export class ExampleComponent { + readonly Icons = { ExampleIcon, Example2Icon }; + ... + } + ``` + + - **HTML:** + + > NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an + > `ariaLabel` is explicitly provided to the `` component + + ```html + + ``` + + With `ariaLabel` + + ```html + + ``` + +9. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client + which supports multiple style modes. diff --git a/libs/components/src/svg/svg.module.ts b/libs/components/src/svg/svg.module.ts new file mode 100644 index 00000000000..c1cdae0e232 --- /dev/null +++ b/libs/components/src/svg/svg.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from "@angular/core"; + +import { SvgComponent } from "./svg.component"; + +@NgModule({ + imports: [SvgComponent], + exports: [SvgComponent], +}) +export class SvgModule {} diff --git a/libs/components/src/svg/svg.stories.ts b/libs/components/src/svg/svg.stories.ts new file mode 100644 index 00000000000..adc5f618664 --- /dev/null +++ b/libs/components/src/svg/svg.stories.ts @@ -0,0 +1,50 @@ +import { Meta } from "@storybook/angular"; + +import * as SvgIcons from "@bitwarden/assets/svg"; + +import { SvgComponent } from "./svg.component"; + +export default { + title: "Component Library/Svg", + component: SvgComponent, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-50335&t=k6OTDDPZOTtypRqo-11", + }, + }, +} as Meta; + +const { + // Filtering out the few non-icons in the libs/assets/svg import + + DynamicContentNotAllowedError: _DynamicContentNotAllowedError, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isBitSvg, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + svg, + ...Icons +}: { + [key: string]: any; +} = SvgIcons; + +export const Default = { + render: (args: { icons: [string, any][] }) => ({ + props: args, + template: /*html*/ ` +
    + @for (icon of icons; track icon[0]) { +
    +
    {{icon[0]}}
    +
    + +
    +
    + } +
    + `, + }), + args: { + icons: Object.entries(Icons), + }, +}; diff --git a/libs/eslint/components/index.mjs b/libs/eslint/components/index.mjs index 273c29890fe..101fdde414c 100644 --- a/libs/eslint/components/index.mjs +++ b/libs/eslint/components/index.mjs @@ -1,9 +1,11 @@ import requireLabelOnBiticonbutton from "./require-label-on-biticonbutton.mjs"; import requireThemeColorsInSvg from "./require-theme-colors-in-svg.mjs"; +import noBwiClassUsage from "./no-bwi-class-usage.mjs"; export default { rules: { "require-label-on-biticonbutton": requireLabelOnBiticonbutton, "require-theme-colors-in-svg": requireThemeColorsInSvg, + "no-bwi-class-usage": noBwiClassUsage, }, }; diff --git a/libs/eslint/components/no-bwi-class-usage.mjs b/libs/eslint/components/no-bwi-class-usage.mjs new file mode 100644 index 00000000000..8260587ce45 --- /dev/null +++ b/libs/eslint/components/no-bwi-class-usage.mjs @@ -0,0 +1,45 @@ +export const errorMessage = + "Use component instead of applying 'bwi' classes directly. Example: "; + +export default { + meta: { + type: "suggestion", + docs: { + description: + "Discourage using 'bwi' font icon classes directly in favor of the component", + category: "Best Practices", + recommended: true, + }, + schema: [], + }, + create(context) { + return { + Element(node) { + // Get all class-related attributes + const classAttrs = [ + ...(node.attributes?.filter((attr) => attr.name === "class") ?? []), + ...(node.inputs?.filter((input) => input.name === "class") ?? []), + ...(node.templateAttrs?.filter((attr) => attr.name === "class") ?? []), + ]; + + for (const classAttr of classAttrs) { + const classValue = classAttr.value || ""; + + // Check if the class value contains 'bwi' or 'bwi-' + // This handles both string literals and template expressions + const hasBwiClass = + typeof classValue === "string" && /\bbwi(?:-[\w-]+)?\b/.test(classValue); + + if (hasBwiClass) { + context.report({ + node, + message: errorMessage, + }); + // Only report once per element + break; + } + } + }, + }; + }, +}; diff --git a/libs/eslint/components/no-bwi-class-usage.spec.mjs b/libs/eslint/components/no-bwi-class-usage.spec.mjs new file mode 100644 index 00000000000..abb5ebe3b29 --- /dev/null +++ b/libs/eslint/components/no-bwi-class-usage.spec.mjs @@ -0,0 +1,44 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; + +import rule, { errorMessage } from "./no-bwi-class-usage.mjs"; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: require("@angular-eslint/template-parser"), + }, +}); + +ruleTester.run("no-bwi-class-usage", rule.default, { + valid: [ + { + name: "should allow bit-icon component usage", + code: ``, + }, + { + name: "should allow elements without bwi classes", + code: `
    `, + }, + ], + invalid: [ + { + name: "should error on direct bwi class usage", + code: ``, + errors: [{ message: errorMessage }], + }, + { + name: "should error on bwi class with other classes", + code: ``, + errors: [{ message: errorMessage }], + }, + { + name: "should error on single bwi-* class", + code: ``, + errors: [{ message: errorMessage }], + }, + { + name: "should error on bwi-fw modifier", + code: ``, + errors: [{ message: errorMessage }], + }, + ], +}); diff --git a/libs/eslint/components/require-theme-colors-in-svg.mjs b/libs/eslint/components/require-theme-colors-in-svg.mjs index fcc9cba461c..d30840710ca 100644 --- a/libs/eslint/components/require-theme-colors-in-svg.mjs +++ b/libs/eslint/components/require-theme-colors-in-svg.mjs @@ -25,7 +25,7 @@ export default { tagNames: { type: "array", items: { type: "string" }, - default: ["svgIcon"], + default: ["svg"], }, }, additionalProperties: false, @@ -35,7 +35,7 @@ export default { create(context) { const options = context.options[0] || {}; - const tagNames = options.tagNames || ["svgIcon"]; + const tagNames = options.tagNames || ["svg"]; function isSvgTaggedTemplate(node) { return ( diff --git a/libs/eslint/components/require-theme-colors-in-svg.spec.mjs b/libs/eslint/components/require-theme-colors-in-svg.spec.mjs index fd513ba57b3..f51871fdc9a 100644 --- a/libs/eslint/components/require-theme-colors-in-svg.spec.mjs +++ b/libs/eslint/components/require-theme-colors-in-svg.spec.mjs @@ -17,36 +17,36 @@ ruleTester.run("require-theme-colors-in-svg", rule.default, { valid: [ { name: "Allows fill=none", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', }, { name: "Allows CSS variable", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', }, { name: "Allows class-based coloring", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', }, ], invalid: [ { name: "Errors on fill with hex color", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "#000000" } }], }, { name: "Errors on stroke with named color", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "red" } }], }, { name: "Errors on fill with rgb()", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "rgb(255,0,0)" } }], }, { name: "Errors on fill with named color", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "blue" } }], }, ], diff --git a/libs/importer/src/importers/keepass2-xml-importer.spec.ts b/libs/importer/src/importers/keepass2-xml-importer.spec.ts index 8fbb021883c..c1c0947936b 100644 --- a/libs/importer/src/importers/keepass2-xml-importer.spec.ts +++ b/libs/importer/src/importers/keepass2-xml-importer.spec.ts @@ -1,3 +1,4 @@ +import { FieldType } from "@bitwarden/common/vault/enums"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { KeePass2XmlImporter } from "./keepass2-xml-importer"; @@ -5,6 +6,7 @@ import { TestData, TestData1, TestData2, + TestDataWithProtectedFields, } from "./spec-data/keepass2-xml/keepass2-xml-importer-testdata"; describe("KeePass2 Xml Importer", () => { @@ -43,4 +45,73 @@ describe("KeePass2 Xml Importer", () => { const result = await importer.parse(TestData2); expect(result.success).toBe(false); }); + + describe("protected fields handling", () => { + it("should import protected custom fields as hidden fields", async () => { + const importer = new KeePass2XmlImporter(); + const result = await importer.parse(TestDataWithProtectedFields); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + + const cipher = result.ciphers[0]; + expect(cipher.name).toBe("Test Entry"); + expect(cipher.login.username).toBe("testuser"); + expect(cipher.login.password).toBe("testpass"); + expect(cipher.notes).toContain("Regular notes"); + + // Check that protected custom field is imported as hidden field + const protectedField = cipher.fields.find((f) => f.name === "SAFE UN-LOCKING instructions"); + expect(protectedField).toBeDefined(); + expect(protectedField?.value).toBe("Secret instructions here"); + expect(protectedField?.type).toBe(FieldType.Hidden); + + // Check that regular custom field is imported as text field + const regularField = cipher.fields.find((f) => f.name === "CustomField"); + expect(regularField).toBeDefined(); + expect(regularField?.value).toBe("Custom value"); + expect(regularField?.type).toBe(FieldType.Text); + }); + + it("should import long protected fields as hidden fields (not appended to notes)", async () => { + const importer = new KeePass2XmlImporter(); + const result = await importer.parse(TestDataWithProtectedFields); + + const cipher = result.ciphers[0]; + + // Long protected field should be imported as hidden field + const longField = cipher.fields.find((f) => f.name === "LongProtectedField"); + expect(longField).toBeDefined(); + expect(longField?.type).toBe(FieldType.Hidden); + expect(longField?.value).toContain("This is a very long protected field"); + + // Should not be appended to notes + expect(cipher.notes).not.toContain("LongProtectedField"); + }); + + it("should import multiline protected fields as hidden fields (not appended to notes)", async () => { + const importer = new KeePass2XmlImporter(); + const result = await importer.parse(TestDataWithProtectedFields); + + const cipher = result.ciphers[0]; + + // Multiline protected field should be imported as hidden field + const multilineField = cipher.fields.find((f) => f.name === "MultilineProtectedField"); + expect(multilineField).toBeDefined(); + expect(multilineField?.type).toBe(FieldType.Hidden); + expect(multilineField?.value).toContain("Line 1"); + + // Should not be appended to notes + expect(cipher.notes).not.toContain("MultilineProtectedField"); + }); + + it("should not append protected custom fields to notes", async () => { + const importer = new KeePass2XmlImporter(); + const result = await importer.parse(TestDataWithProtectedFields); + + const cipher = result.ciphers[0]; + expect(cipher.notes).not.toContain("SAFE UN-LOCKING instructions"); + expect(cipher.notes).not.toContain("Secret instructions here"); + }); + }); }); diff --git a/libs/importer/src/importers/keepass2-xml-importer.ts b/libs/importer/src/importers/keepass2-xml-importer.ts index 0af7a6f829c..429ab2aa1b7 100644 --- a/libs/importer/src/importers/keepass2-xml-importer.ts +++ b/libs/importer/src/importers/keepass2-xml-importer.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { FieldType } from "@bitwarden/common/vault/enums"; +import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ImportResult } from "../models/import-result"; @@ -92,16 +93,26 @@ export class KeePass2XmlImporter extends BaseImporter implements Importer { } else if (key === "Notes") { cipher.notes += value + "\n"; } else { - let type = FieldType.Text; const attrs = valueEl.attributes as any; - if ( + const isProtected = attrs.length > 0 && attrs.ProtectInMemory != null && - attrs.ProtectInMemory.value === "True" - ) { - type = FieldType.Hidden; + attrs.ProtectInMemory.value === "True"; + + if (isProtected) { + // Protected fields should always be imported as hidden fields, + // regardless of length or newlines (fixes #16897) + if (cipher.fields == null) { + cipher.fields = []; + } + const field = new FieldView(); + field.type = FieldType.Hidden; + field.name = key; + field.value = value; + cipher.fields.push(field); + } else { + this.processKvp(cipher, key, value, FieldType.Text); } - this.processKvp(cipher, key, value, type); } }); diff --git a/libs/importer/src/importers/roboform-csv-importer.ts b/libs/importer/src/importers/roboform-csv-importer.ts index eb8a1ceac6a..6f557bb0db5 100644 --- a/libs/importer/src/importers/roboform-csv-importer.ts +++ b/libs/importer/src/importers/roboform-csv-importer.ts @@ -29,8 +29,9 @@ export class RoboFormCsvImporter extends BaseImporter implements Importer { cipher.notes = this.getValueOrDefault(value.Note); cipher.name = this.getValueOrDefault(value.Name, "--"); cipher.login.username = this.getValueOrDefault(value.Login); - cipher.login.password = this.getValueOrDefault(value.Pwd); - cipher.login.uris = this.makeUriArray(value.Url); + cipher.login.password = + this.getValueOrDefault(value.Pwd) ?? this.getValueOrDefault(value.Password); + cipher.login.uris = this.makeUriArray(value.Url) ?? this.makeUriArray(value.URL); if (!this.isNullOrWhitespace(value.Rf_fields)) { this.parseRfFields(cipher, value); diff --git a/libs/importer/src/importers/spec-data/keepass2-xml/keepass2-xml-importer-testdata.ts b/libs/importer/src/importers/spec-data/keepass2-xml/keepass2-xml-importer-testdata.ts index e06ca2cf655..9e1599b7078 100644 --- a/libs/importer/src/importers/spec-data/keepass2-xml/keepass2-xml-importer-testdata.ts +++ b/libs/importer/src/importers/spec-data/keepass2-xml/keepass2-xml-importer-testdata.ts @@ -354,6 +354,57 @@ line2 `; +export const TestDataWithProtectedFields = ` + + + + KvS57lVwl13AfGFLwkvq4Q== + Root + + fAa543oYlgnJKkhKag5HLw== + + Title + Test Entry + + + UserName + testuser + + + Password + testpass + + + URL + https://example.com + + + Notes + Regular notes + + + SAFE UN-LOCKING instructions + Secret instructions here + + + CustomField + Custom value + + + LongProtectedField + This is a very long protected field value that exceeds 200 characters. It contains sensitive information that should be imported as a hidden field and not appended to the notes section. This text is long enough to trigger the old behavior. + + + MultilineProtectedField + Line 1 +Line 2 +Line 3 + + + + +`; + export const TestData2 = ` KeePass diff --git a/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts b/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts index 960a663b589..106037bc5f7 100644 --- a/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts +++ b/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts @@ -14,6 +14,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils"; import { UserId } from "@bitwarden/common/types/guid"; import { PrfKey, UserKey } from "@bitwarden/common/types/key"; @@ -267,7 +268,7 @@ export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService private async getRpIdForUser(userId: UserId): Promise { try { const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId)); - const hostname = environment.getHostname(); + const hostname = Utils.getHost(environment.getWebVaultUrl()); // The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host. if (!hostname) { diff --git a/libs/key-management/src/biometrics/biometric-state.service.spec.ts b/libs/key-management/src/biometrics/biometric-state.service.spec.ts index 32043514ff7..2f1f189a897 100644 --- a/libs/key-management/src/biometrics/biometric-state.service.spec.ts +++ b/libs/key-management/src/biometrics/biometric-state.service.spec.ts @@ -179,18 +179,36 @@ describe("BiometricStateService", () => { }); describe("biometricUnlockEnabled$", () => { - it("emits when biometricUnlockEnabled state is updated", async () => { - const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); - state.nextState(true); + describe("no user id provided, active user", () => { + it("emits when biometricUnlockEnabled state is updated", async () => { + const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); + state.nextState(true); - expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true); + expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(true); + }); + + it("emits false when biometricUnlockEnabled state is undefined", async () => { + const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); + state.nextState(undefined as unknown as boolean); + + expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(false); + }); }); - it("emits false when biometricUnlockEnabled state is undefined", async () => { - const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); - state.nextState(undefined as unknown as boolean); + describe("user id provided", () => { + it("returns biometricUnlockEnabled state for the given user", async () => { + stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true); - expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(false); + expect(await firstValueFrom(sut.biometricUnlockEnabled$(userId))).toBe(true); + }); + + it("returns false when the state is not set", async () => { + stateProvider.singleUser + .getFake(userId, BIOMETRIC_UNLOCK_ENABLED) + .nextState(undefined as unknown as boolean); + + expect(await firstValueFrom(sut.biometricUnlockEnabled$(userId))).toBe(false); + }); }); }); @@ -198,7 +216,7 @@ describe("BiometricStateService", () => { it("updates biometricUnlockEnabled$", async () => { await sut.setBiometricUnlockEnabled(true); - expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true); + expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(true); }); it("updates state", async () => { @@ -210,22 +228,6 @@ describe("BiometricStateService", () => { }); }); - describe("getBiometricUnlockEnabled", () => { - it("returns biometricUnlockEnabled state for the given user", async () => { - stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true); - - expect(await sut.getBiometricUnlockEnabled(userId)).toBe(true); - }); - - it("returns false when the state is not set", async () => { - stateProvider.singleUser - .getFake(userId, BIOMETRIC_UNLOCK_ENABLED) - .nextState(undefined as unknown as boolean); - - expect(await sut.getBiometricUnlockEnabled(userId)).toBe(false); - }); - }); - describe("setFingerprintValidated", () => { it("updates fingerprintValidated$", async () => { await sut.setFingerprintValidated(true); diff --git a/libs/key-management/src/biometrics/biometric-state.service.ts b/libs/key-management/src/biometrics/biometric-state.service.ts index 1488f12b50b..ca1cbcfa871 100644 --- a/libs/key-management/src/biometrics/biometric-state.service.ts +++ b/libs/key-management/src/biometrics/biometric-state.service.ts @@ -18,9 +18,11 @@ import { export abstract class BiometricStateService { /** - * `true` if the currently active user has elected to store a biometric key to unlock their vault. + * Returns whether biometric unlock is enabled for a user. + * @param userId The user id to check. If not provided, returns the state for the currently active user. + * @returns An observable that emits `true` if the user has elected to store a biometric key to unlock their vault. */ - abstract biometricUnlockEnabled$: Observable; // used to be biometricUnlock + abstract biometricUnlockEnabled$(userId?: UserId): Observable; /** * If the user has elected to require a password on first unlock of an application instance, this key will store the * encrypted client key half used to unlock the vault. @@ -53,6 +55,7 @@ export abstract class BiometricStateService { /** * Gets the biometric unlock enabled state for the given user. + * @deprecated Use {@link biometricUnlockEnabled$} instead * @param userId user Id to check */ abstract getBiometricUnlockEnabled(userId: UserId): Promise; @@ -103,7 +106,6 @@ export class DefaultBiometricStateService implements BiometricStateService { private promptAutomaticallyState: ActiveUserState; private fingerprintValidatedState: GlobalState; private lastProcessReloadState: GlobalState; - biometricUnlockEnabled$: Observable; encryptedClientKeyHalf$: Observable; promptCancelled$: Observable; promptAutomatically$: Observable; @@ -112,7 +114,6 @@ export class DefaultBiometricStateService implements BiometricStateService { constructor(private stateProvider: StateProvider) { this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED); - this.biometricUnlockEnabled$ = this.biometricUnlockEnabledState.state$.pipe(map(Boolean)); this.encryptedClientKeyHalfState = this.stateProvider.getActive(ENCRYPTED_CLIENT_KEY_HALF); this.encryptedClientKeyHalf$ = this.encryptedClientKeyHalfState.state$.pipe( @@ -142,6 +143,15 @@ export class DefaultBiometricStateService implements BiometricStateService { await this.biometricUnlockEnabledState.update(() => enabled); } + biometricUnlockEnabled$(userId?: UserId): Observable { + if (userId != null) { + return this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)); + } + // Backwards compatibility for active user state + // TODO remove with https://bitwarden.atlassian.net/browse/PM-12043 + return this.biometricUnlockEnabledState.state$.pipe(map(Boolean)); + } + async getBiometricUnlockEnabled(userId: UserId): Promise { return await firstValueFrom( this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)), diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts index 735d694152c..669b54c5b57 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { BadgeVariant, ButtonType, IconModule, TypographyModule } from "@bitwarden/components"; +import { BadgeVariant, ButtonType, SvgModule, TypographyModule } from "@bitwarden/components"; import { PricingCardComponent } from "@bitwarden/pricing"; @Component({ @@ -68,13 +68,7 @@ describe("PricingCardComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - PricingCardComponent, - TestHostComponent, - IconModule, - TypographyModule, - CommonModule, - ], + imports: [PricingCardComponent, TestHostComponent, SvgModule, TypographyModule, CommonModule], }).compileComponents(); // For signal inputs, we need to set required inputs through the host component diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.ts index c9da7c32462..4b9241fc9dd 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.ts @@ -7,7 +7,7 @@ import { ButtonModule, ButtonType, CardComponent, - IconModule, + SvgModule, TypographyModule, } from "@bitwarden/components"; @@ -20,7 +20,7 @@ import { selector: "billing-pricing-card", templateUrl: "./pricing-card.component.html", changeDetection: ChangeDetectionStrategy.OnPush, - imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe, CardComponent], + imports: [BadgeModule, ButtonModule, SvgModule, TypographyModule, CurrencyPipe, CardComponent], }) export class PricingCardComponent { readonly tagline = input.required(); diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html index a271788b0ef..3f28ed289c9 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html @@ -7,64 +7,22 @@ {{ "limitSendViews" | i18n }} {{ "limitSendViewsHint" | i18n }} -  {{ "limitSendViewsCount" | i18n: viewsLeft }} + @if (shouldShowCount) { +  {{ "limitSendViewsCount" | i18n: viewsLeft }} + } - - {{ (passwordRemoved ? "newPassword" : "password") | i18n }} - - - - - - {{ "sendPasswordDescV3" | i18n }} - - - - {{ "hideYourEmail" | i18n }} - + + @if (!disableHideEmail || originalSendView?.hideEmail) { + + + {{ "hideYourEmail" | i18n }} + + } {{ "privateNote" | i18n }} diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts index fa069b92ed2..47e8403f770 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts @@ -5,12 +5,7 @@ import { of } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; -import { DialogService, ToastService } from "@bitwarden/components"; -import { CredentialGeneratorService } from "@bitwarden/generator-core"; import { SendFormContainer } from "../../send-form-container"; @@ -32,14 +27,9 @@ describe("SendOptionsComponent", () => { declarations: [], providers: [ { provide: SendFormContainer, useValue: mockSendFormContainer }, - { provide: DialogService, useValue: mock() }, - { provide: SendApiService, useValue: mock() }, { provide: PolicyService, useValue: mock() }, { provide: I18nService, useValue: mock() }, - { provide: ToastService, useValue: mock() }, - { provide: CredentialGeneratorService, useValue: mock() }, { provide: AccountService, useValue: mockAccountService }, - { provide: PlatformUtilsService, useValue: mock() }, ], }).compileComponents(); fixture = TestBed.createComponent(SendOptionsComponent); @@ -55,13 +45,4 @@ describe("SendOptionsComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); - - it("should emit a null password when password textbox is empty", async () => { - const newSend = {} as SendView; - mockSendFormContainer.patchSend.mockImplementation((updateFn) => updateFn(newSend)); - component.sendOptionsForm.patchValue({ password: "testing" }); - expect(newSend.password).toBe("testing"); - component.sendOptionsForm.patchValue({ password: "" }); - expect(newSend.password).toBe(null); - }); }); diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts index ae8706a375e..a5f369d66aa 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts @@ -4,32 +4,26 @@ import { CommonModule } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { BehaviorSubject, firstValueFrom, map, switchMap, tap } from "rxjs"; +import { switchMap, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { pin } from "@bitwarden/common/tools/rx"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { + TypographyModule, AsyncActionsModule, ButtonModule, CardComponent, CheckboxModule, - DialogService, FormFieldModule, IconButtonModule, SectionComponent, SectionHeaderComponent, - ToastService, - TypographyModule, + SelectModule, } from "@bitwarden/components"; -import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; @@ -39,6 +33,7 @@ import { SendFormContainer } from "../../send-form-container"; @Component({ selector: "tools-send-options", templateUrl: "./send-options.component.html", + standalone: true, imports: [ AsyncActionsModule, ButtonModule, @@ -51,6 +46,7 @@ import { SendFormContainer } from "../../send-form-container"; ReactiveFormsModule, SectionComponent, SectionHeaderComponent, + SelectModule, TypographyModule, ], }) @@ -64,19 +60,14 @@ export class SendOptionsComponent implements OnInit { @Input() originalSendView: SendView; disableHideEmail = false; - passwordRemoved = false; + sendOptionsForm = this.formBuilder.group({ maxAccessCount: [null as number], accessCount: [null as number], notes: [null as string], - password: [null as string], hideEmail: [false as boolean], }); - get hasPassword(): boolean { - return this.originalSendView && this.originalSendView.password !== null; - } - get shouldShowCount(): boolean { return this.config.mode === "edit" && this.sendOptionsForm.value.maxAccessCount !== null; } @@ -91,13 +82,8 @@ export class SendOptionsComponent implements OnInit { constructor( private sendFormContainer: SendFormContainer, - private dialogService: DialogService, - private sendApiService: SendApiService, private formBuilder: FormBuilder, private policyService: PolicyService, - private i18nService: I18nService, - private toastService: ToastService, - private generatorService: CredentialGeneratorService, private accountService: AccountService, ) { this.sendFormContainer.registerChildForm("sendOptionsForm", this.sendOptionsForm); @@ -113,87 +99,28 @@ export class SendOptionsComponent implements OnInit { this.disableHideEmail = disableHideEmail; }); - this.sendOptionsForm.valueChanges - .pipe( - tap((value) => { - if (Utils.isNullOrWhitespace(value.password)) { - value.password = null; - } - }), - takeUntilDestroyed(), - ) - .subscribe((value) => { - this.sendFormContainer.patchSend((send) => { - Object.assign(send, { - maxAccessCount: value.maxAccessCount, - accessCount: value.accessCount, - password: value.password, - hideEmail: value.hideEmail, - notes: value.notes, - }); - return send; + this.sendOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { + this.sendFormContainer.patchSend((send) => { + Object.assign(send, { + maxAccessCount: value.maxAccessCount, + accessCount: value.accessCount, + hideEmail: value.hideEmail, + notes: value.notes, }); + return send; }); + }); } - generatePassword = async () => { - const on$ = new BehaviorSubject({ source: "send", type: Type.password }); - const account$ = this.accountService.activeAccount$.pipe( - pin({ name: () => "send-options.component", distinct: (p, c) => p.id === c.id }), - ); - const generatedCredential = await firstValueFrom( - this.generatorService.generate$({ on$, account$ }), - ); - - this.sendOptionsForm.patchValue({ - password: generatedCredential.credential, - }); - }; - - removePassword = async () => { - if (!this.originalSendView || !this.originalSendView.password) { - return; - } - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "removePassword" }, - content: { key: "removePasswordConfirmation" }, - type: "warning", - }); - - if (!confirmed) { - return false; - } - - this.passwordRemoved = true; - - await this.sendApiService.removePassword(this.originalSendView.id); - - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("removedPassword"), - }); - - this.originalSendView.password = null; - this.sendOptionsForm.patchValue({ - password: null, - }); - this.sendOptionsForm.get("password")?.enable(); - }; - ngOnInit() { if (this.sendFormContainer.originalSendView) { this.sendOptionsForm.patchValue({ maxAccessCount: this.sendFormContainer.originalSendView.maxAccessCount, accessCount: this.sendFormContainer.originalSendView.accessCount, - password: this.hasPassword ? "************" : null, // 12 masked characters as a placeholder hideEmail: this.sendFormContainer.originalSendView.hideEmail, notes: this.sendFormContainer.originalSendView.notes, }); } - if (this.hasPassword) { - this.sendOptionsForm.get("password")?.disable(); - } if (!this.config.areSendsAllowed) { this.sendOptionsForm.disable(); diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index e650ca3a5df..581ee20caf7 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -6,7 +6,7 @@ {{ "name" | i18n }} - + - + {{ "deletionDate" | i18n }} {{ "deletionDateDescV2" | i18n }} + + + {{ "whoCanView" | i18n }} + + @for (option of availableAuthTypes$ | async; track option.value) { + + } + + @if (sendDetailsForm.get("authType").value === AuthType.Email) { + {{ "emailVerificationDesc" | i18n }} + } + + + @if (sendDetailsForm.get("authType").value === AuthType.Password) { + + {{ (passwordRemoved ? "newPassword" : "password") | i18n }} + +
    + @if (!hasPassword) { + + + + } @else { + + } +
    + {{ "sendPasswordDescV3" | i18n }} +
    + } + + @if (sendDetailsForm.get("authType").value === AuthType.Email) { + + {{ "emails" | i18n }} + + {{ "enterMultipleEmailsSeparatedByComma" | i18n }} + + } diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts index 576842cd877..f816c9d5ce4 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts @@ -1,4 +1,29 @@ -import { DatePreset, isDatePreset, asDatePreset } from "./send-details.component"; +import { DatePipe } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { CredentialGeneratorService } from "@bitwarden/generator-core"; + +import { SendFormContainer } from "../../send-form-container"; + +import { + DatePreset, + SendDetailsComponent, + asDatePreset, + isDatePreset, +} from "./send-details.component"; describe("SendDetails DatePreset utilities", () => { it("accepts all defined numeric presets", () => { @@ -25,3 +50,81 @@ describe("SendDetails DatePreset utilities", () => { }); }); }); + +describe("SendDetailsComponent", () => { + let component: SendDetailsComponent; + let fixture: ComponentFixture; + const mockSendFormContainer = mock(); + const mockI18nService = mock(); + const mockConfigService = mock(); + const mockAccountService = mock(); + const mockBillingStateService = mock(); + const mockGeneratorService = mock(); + const mockSendApiService = mock(); + const mockEnvironmentService = mock(); + + beforeEach(async () => { + mockEnvironmentService.environment$ = of({ + getSendUrl: () => "https://send.bitwarden.com/", + } as any); + mockAccountService.activeAccount$ = of({ id: "userId" } as Account); + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + mockI18nService.t.mockImplementation((k) => k); + + await TestBed.configureTestingModule({ + imports: [SendDetailsComponent, ReactiveFormsModule], + providers: [ + { provide: SendFormContainer, useValue: mockSendFormContainer }, + { provide: I18nService, useValue: mockI18nService }, + { provide: DatePipe, useValue: new DatePipe("en-US") }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: BillingAccountProfileStateService, useValue: mockBillingStateService }, + { provide: CredentialGeneratorService, useValue: mockGeneratorService }, + { provide: SendApiService, useValue: mockSendApiService }, + { provide: PolicyService, useValue: mock() }, + { provide: DialogService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SendDetailsComponent); + component = fixture.componentInstance; + component.config = { areSendsAllowed: true, mode: "add", sendType: SendType.Text }; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize authType to None if no password or emails", () => { + expect(component.sendDetailsForm.value.authType).toBe(AuthType.None); + }); + + it("should toggle validation based on authType", () => { + const emailsControl = component.sendDetailsForm.get("emails"); + const passwordControl = component.sendDetailsForm.get("password"); + + // Default + expect(emailsControl?.validator).toBeNull(); + expect(passwordControl?.validator).toBeNull(); + + // Select Email + component.sendDetailsForm.patchValue({ authType: AuthType.Email }); + expect(emailsControl?.validator).not.toBeNull(); + expect(passwordControl?.validator).toBeNull(); + + // Select Password + component.sendDetailsForm.patchValue({ authType: AuthType.Password }); + expect(passwordControl?.validator).not.toBeNull(); + expect(emailsControl?.validator).toBeNull(); + + // Select None + component.sendDetailsForm.patchValue({ authType: AuthType.None }); + expect(emailsControl?.validator).toBeNull(); + expect(passwordControl?.validator).toBeNull(); + }); +}); diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index e2b50eafc99..463f3195645 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -3,13 +3,28 @@ import { CommonModule, DatePipe } from "@angular/common"; import { Component, OnInit, Input } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; -import { firstValueFrom } from "rxjs"; +import { + FormBuilder, + FormControl, + ReactiveFormsModule, + Validators, + ValidatorFn, + ValidationErrors, +} from "@angular/forms"; +import { firstValueFrom, BehaviorSubject, combineLatest, map, switchMap, tap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { pin } from "@bitwarden/common/tools/rx"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SectionComponent, @@ -20,7 +35,12 @@ import { IconButtonModule, CheckboxModule, SelectModule, + AsyncActionsModule, + ButtonModule, + ToastService, + DialogService, } from "@bitwarden/components"; +import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; @@ -78,6 +98,7 @@ export function asDatePreset(value: unknown): DatePreset | undefined { @Component({ selector: "tools-send-details", templateUrl: "./send-details.component.html", + standalone: true, imports: [ SectionComponent, SectionHeaderComponent, @@ -92,7 +113,10 @@ export function asDatePreset(value: unknown): DatePreset | undefined { IconButtonModule, CheckboxModule, CommonModule, + CommonModule, SelectModule, + AsyncActionsModule, + ButtonModule, ], }) export class SendDetailsComponent implements OnInit { @@ -105,31 +129,110 @@ export class SendDetailsComponent implements OnInit { FileSendType = SendType.File; TextSendType = SendType.Text; + readonly AuthType = AuthType; sendLink: string | null = null; customDeletionDateOption: DatePresetSelectOption | null = null; datePresetOptions: DatePresetSelectOption[] = []; + passwordRemoved = false; + + emailVerificationFeatureFlag$ = this.configService.getFeatureFlag$(FeatureFlag.SendEmailOTP); + hasPremium$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ), + ); + + authTypes: { name: string; value: AuthType; disabled?: boolean }[] = [ + { name: this.i18nService.t("noAuth"), value: AuthType.None }, + { name: this.i18nService.t("specificPeople"), value: AuthType.Email }, + { name: this.i18nService.t("anyOneWithPassword"), value: AuthType.Password }, + ]; + + availableAuthTypes$ = combineLatest([this.emailVerificationFeatureFlag$, this.hasPremium$]).pipe( + map(([enabled, hasPremium]) => { + if (!enabled || !hasPremium) { + return this.authTypes.filter((t) => t.value !== AuthType.Email); + } + return this.authTypes; + }), + ); sendDetailsForm = this.formBuilder.group({ name: new FormControl("", Validators.required), selectedDeletionDatePreset: new FormControl(DatePreset.SevenDays || "", Validators.required), + authType: [AuthType.None as AuthType], + password: [null as string], + emails: [null as string], }); + get hasPassword(): boolean { + return this.originalSendView?.password != null; + } + constructor( protected sendFormContainer: SendFormContainer, protected formBuilder: FormBuilder, protected i18nService: I18nService, protected datePipe: DatePipe, protected environmentService: EnvironmentService, + private configService: ConfigService, + private accountService: AccountService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private generatorService: CredentialGeneratorService, + private sendApiService: SendApiService, + private dialogService: DialogService, + private toastService: ToastService, ) { - this.sendDetailsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { - this.sendFormContainer.patchSend((send) => { - return Object.assign(send, { - name: value.name, - deletionDate: new Date(this.formattedDeletionDate), - expirationDate: new Date(this.formattedDeletionDate), - } as SendView); + this.sendDetailsForm.valueChanges + .pipe( + tap((value) => { + if (Utils.isNullOrWhitespace(value.password)) { + value.password = null; + } + }), + takeUntilDestroyed(), + ) + .subscribe((value) => { + this.sendFormContainer.patchSend((send) => { + return Object.assign(send, { + name: value.name, + deletionDate: new Date(this.formattedDeletionDate), + expirationDate: new Date(this.formattedDeletionDate), + password: value.password, + emails: value.emails + ? value.emails + .split(",") + .map((e) => e.trim()) + .filter((e) => e.length > 0) + : null, + } as unknown as SendView); + }); + }); + + this.sendDetailsForm + .get("authType") + .valueChanges.pipe(takeUntilDestroyed()) + .subscribe((type) => { + const emailsControl = this.sendDetailsForm.get("emails"); + const passwordControl = this.sendDetailsForm.get("password"); + + if (type === AuthType.Password) { + emailsControl.setValue(null); + emailsControl.clearValidators(); + passwordControl.setValidators([Validators.required]); + } else if (type === AuthType.Email) { + passwordControl.setValue(null); + passwordControl.clearValidators(); + emailsControl.setValidators([Validators.required, this.emailListValidator()]); + } else { + emailsControl.setValue(null); + emailsControl.clearValidators(); + passwordControl.setValue(null); + passwordControl.clearValidators(); + } + emailsControl.updateValueAndValidity(); + passwordControl.updateValueAndValidity(); }); - }); this.sendFormContainer.registerChildForm("sendDetailsForm", this.sendDetailsForm); } @@ -141,8 +244,15 @@ export class SendDetailsComponent implements OnInit { this.sendDetailsForm.patchValue({ name: this.originalSendView.name, selectedDeletionDatePreset: this.originalSendView.deletionDate.toString(), + password: this.hasPassword ? "************" : null, + authType: this.originalSendView.authType, + emails: this.originalSendView.emails?.join(", ") ?? null, }); + if (this.hasPassword) { + this.sendDetailsForm.get("password")?.disable(); + } + if (this.originalSendView.deletionDate) { this.customDeletionDateOption = { name: this.datePipe.transform(this.originalSendView.deletionDate, "short"), @@ -193,4 +303,61 @@ export class SendDetailsComponent implements OnInit { const milliseconds = now.setTime(now.getTime() + preset * 60 * 60 * 1000); return new Date(milliseconds).toString(); } + + emailListValidator(): ValidatorFn { + return (control: FormControl): ValidationErrors | null => { + if (!control.value) { + return null; + } + const emails = control.value.split(",").map((e: string) => e.trim()); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const invalidEmails = emails.filter((e: string) => e.length > 0 && !emailRegex.test(e)); + return invalidEmails.length > 0 ? { email: true } : null; + }; + } + + generatePassword = async () => { + const on$ = new BehaviorSubject({ source: "send", type: Type.password }); + const account$ = this.accountService.activeAccount$.pipe( + pin({ name: () => "send-details.component", distinct: (p, c) => p.id === c.id }), + ); + const generatedCredential = await firstValueFrom( + this.generatorService.generate$({ on$, account$ }), + ); + + this.sendDetailsForm.patchValue({ + password: generatedCredential.credential, + }); + }; + + removePassword = async () => { + if (!this.originalSendView?.password) { + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "removePassword" }, + content: { key: "removePasswordConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + this.passwordRemoved = true; + + await this.sendApiService.removePassword(this.originalSendView.id); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("removedPassword"), + }); + + this.originalSendView.password = null; + this.sendDetailsForm.patchValue({ + password: null, + }); + this.sendDetailsForm.get("password")?.enable(); + }; } diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html index 7af120cfd6c..913d1b7963b 100644 --- a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html @@ -9,5 +9,5 @@ [attr.aria-label]="slide.label" (click)="onClick.emit()" > - + diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts index bef7f5b12d6..42fe082d5f8 100644 --- a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from "@angular/core"; import { CarouselIcon } from "@bitwarden/assets/svg"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.component"; @@ -12,7 +12,7 @@ import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.co @Component({ selector: "vault-carousel-button", templateUrl: "carousel-button.component.html", - imports: [CommonModule, IconModule], + imports: [CommonModule, SvgModule], }) export class VaultCarouselButtonComponent implements FocusableOption { /** Slide component that is associated with the individual button */ diff --git a/package-lock.json b/package-lock.json index 71da4287031..f8117a5cd1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,15 +14,15 @@ "libs/**/*" ], "dependencies": { - "@angular/animations": "20.3.15", + "@angular/animations": "20.3.16", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.15", - "@angular/compiler": "20.3.15", - "@angular/core": "20.3.15", - "@angular/forms": "20.3.15", - "@angular/platform-browser": "20.3.15", - "@angular/platform-browser-dynamic": "20.3.15", - "@angular/router": "20.3.15", + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/forms": "20.3.16", + "@angular/platform-browser": "20.3.16", + "@angular/platform-browser-dynamic": "20.3.16", + "@angular/router": "20.3.16", "@bitwarden/commercial-sdk-internal": "0.2.0-main.470", "@bitwarden/sdk-internal": "0.2.0-main.470", "@electron/fuses": "1.8.0", @@ -74,7 +74,7 @@ "@angular-devkit/build-angular": "20.3.12", "@angular-eslint/schematics": "20.7.0", "@angular/cli": "20.3.12", - "@angular/compiler-cli": "20.3.15", + "@angular/compiler-cli": "20.3.16", "@babel/core": "7.28.5", "@babel/preset-env": "7.28.5", "@compodoc/compodoc": "1.1.32", @@ -107,10 +107,10 @@ "@types/koa__multer": "2.0.7", "@types/koa__router": "12.0.4", "@types/koa-bodyparser": "4.3.7", - "@types/koa-json": "2.0.23", + "@types/koa-json": "2.0.24", "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", - "@types/node": "22.19.3", + "@types/node": "22.19.7", "@types/node-fetch": "2.6.13", "@types/node-forge": "1.3.14", "@types/papaparse": "5.5.0", @@ -128,7 +128,7 @@ "base64-loader": "1.0.0", "browserslist": "4.28.1", "chromatic": "13.3.4", - "concurrently": "9.2.0", + "concurrently": "9.2.1", "copy-webpack-plugin": "13.0.1", "cross-env": "10.1.0", "css-loader": "7.1.2", @@ -2210,9 +2210,9 @@ } }, "node_modules/@angular/animations": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.15.tgz", - "integrity": "sha512-ikyKfhkxoqQA6JcBN0B9RaN6369sM1XYX81Id0lI58dmWCe7gYfrTp8ejqxxKftl514psQO3pkW8Gn1nJ131Gw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.16.tgz", + "integrity": "sha512-N83/GFY5lKNyWgPV3xHHy2rb3/eP1ZLzSVI+dmMVbf3jbqwY1YPQcMiAG8UDzaILY1Dkus91kWLF8Qdr3nHAzg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2221,7 +2221,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.15" + "@angular/core": "20.3.16" } }, "node_modules/@angular/build": { @@ -2634,9 +2634,9 @@ } }, "node_modules/@angular/common": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.15.tgz", - "integrity": "sha512-k4mCXWRFiOHK3bUKfWkRQQ8KBPxW8TAJuKLYCsSHPCpMz6u0eA1F0VlrnOkZVKWPI792fOaEAWH2Y4PTaXlUHw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.16.tgz", + "integrity": "sha512-GRAziNlntwdnJy3F+8zCOvDdy7id0gITjDnM6P9+n2lXvtDuBLGJKU3DWBbvxcCjtD6JK/g/rEX5fbCxbUHkQQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2645,14 +2645,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.15", + "@angular/core": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.15.tgz", - "integrity": "sha512-lMicIAFAKZXa+BCZWs3soTjNQPZZXrF/WMVDinm8dQcggNarnDj4UmXgKSyXkkyqK5SLfnLsXVzrX6ndVT6z7A==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.16.tgz", + "integrity": "sha512-Pt9Ms9GwTThgzdxWBwMfN8cH1JEtQ2DK5dc2yxYtPSaD+WKmG9AVL1PrzIYQEbaKcWk2jxASUHpEWSlNiwo8uw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2662,9 +2662,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.15.tgz", - "integrity": "sha512-8sJoxodxsfyZ8eJ5r6Bx7BCbazXYgsZ1+dE8t5u5rTQ6jNggwNtYEzkyReoD5xvP+MMtRkos3xpwq4rtFnpI6A==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.16.tgz", + "integrity": "sha512-l3xF/fXfJAl/UrNnH9Ufkr79myjMgXdHq1mmmph2UnpeqilRB1b8lC9sLBV9MipQHVn3dwocxMIvtrcryfOaXw==", "dev": true, "license": "MIT", "dependencies": { @@ -2685,7 +2685,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.15", + "@angular/compiler": "20.3.16", "typescript": ">=5.8 <6.0" }, "peerDependenciesMeta": { @@ -2871,9 +2871,9 @@ } }, "node_modules/@angular/core": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.15.tgz", - "integrity": "sha512-NMbX71SlTZIY9+rh/SPhRYFJU0pMJYW7z/TBD4lqiO+b0DTOIg1k7Pg9ydJGqSjFO1Z4dQaA6TteNuF99TJCNw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.16.tgz", + "integrity": "sha512-KSFPKvOmWWLCJBbEO+CuRUXfecX2FRuO0jNi9c54ptXMOPHlK1lIojUnyXmMNzjdHgRug8ci9qDuftvC2B7MKg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2882,7 +2882,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.15", + "@angular/compiler": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0" }, @@ -2896,9 +2896,9 @@ } }, "node_modules/@angular/forms": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.15.tgz", - "integrity": "sha512-gS5hQkinq52pm/7mxz4yHPCzEcmRWjtUkOVddPH0V1BW/HMni/p4Y6k2KqKBeGb9p8S5EAp6PDxDVLOPukp3mg==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.16.tgz", + "integrity": "sha512-1yzbXpExTqATpVcqA3wGrq4ACFIP3mRxA4pbso5KoJU+/4JfzNFwLsDaFXKpm5uxwchVnj8KM2vPaDOkvtp7NA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2907,16 +2907,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.15.tgz", - "integrity": "sha512-TxRM/wTW/oGXv/3/Iohn58yWoiYXOaeEnxSasiGNS1qhbkcKtR70xzxW6NjChBUYAixz2ERkLURkpx3pI8Q6Dw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.16.tgz", + "integrity": "sha512-YsrLS6vyS77i4pVHg4gdSBW74qvzHjpQRTVQ5Lv/OxIjJdYYYkMmjNalCNgy1ZuyY6CaLIB11ccxhrNnxfKGOQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2925,9 +2925,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "20.3.15", - "@angular/common": "20.3.15", - "@angular/core": "20.3.15" + "@angular/animations": "20.3.16", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16" }, "peerDependenciesMeta": { "@angular/animations": { @@ -2936,9 +2936,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.15.tgz", - "integrity": "sha512-RizuRdBt0d6ongQ2y8cr8YsXFyjF8f91vFfpSNw+cFj+oiEmRC1txcWUlH5bPLD9qSDied8qazUi0Tb8VPQDGw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.16.tgz", + "integrity": "sha512-5mECCV9YeKH6ue239GXRTGeDSd/eTbM1j8dDejhm5cGnPBhTxRw4o+GgSrWTYtb6VmIYdwUGBTC+wCBphiaQ2A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2947,16 +2947,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/compiler": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15" + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16" } }, "node_modules/@angular/router": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.15.tgz", - "integrity": "sha512-6+qgk8swGSoAu7ISSY//GatAyCP36hEvvUgvjbZgkXLLH9yUQxdo77ij05aJ5s0OyB25q/JkqS8VTY0z1yE9NQ==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz", + "integrity": "sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2965,9 +2965,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -15795,9 +15795,9 @@ } }, "node_modules/@types/koa-json": { - "version": "2.0.23", - "resolved": "https://registry.npmjs.org/@types/koa-json/-/koa-json-2.0.23.tgz", - "integrity": "sha512-LJKLFouztosawgU5xrtanK4neLCQKXl+vuVN96YMeVdKTYObLq2Qybggm9V426Jwam8Gi/zOrPw1g+QH0VaEHw==", + "version": "2.0.24", + "resolved": "https://registry.npmjs.org/@types/koa-json/-/koa-json-2.0.24.tgz", + "integrity": "sha512-FF+nQil6YO8vXMuLnOgGHYspSZVVpi+W79m9/s7LBSOQhlX7QY02X3Evk/g1GgWNLbO674AQaziX6OCCKzQ6Aw==", "dev": true, "license": "MIT", "dependencies": { @@ -15860,9 +15860,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", - "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -20585,19 +20585,18 @@ } }, "node_modules/concurrently": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", - "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.1.2", - "lodash": "^4.17.21", - "rxjs": "^7.8.1", - "shell-quote": "^1.8.1", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.2" + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", @@ -20610,6 +20609,16 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/concurrently/node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/concurrently/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -32441,9 +32450,9 @@ "license": "MIT" }, "node_modules/msgpackr": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", - "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.8.tgz", + "integrity": "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==", "dev": true, "license": "MIT", "optional": true, @@ -34717,9 +34726,9 @@ } }, "node_modules/ordered-binary": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.0.tgz", - "integrity": "sha512-IQh2aMfMIDbPjI/8a3Edr+PiOpcsB7yo8NdW7aHWVaoR/pcDldunMvnnwbk/auPGqmKeAdxtZl7MHX/QmPwhvQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", "dev": true, "license": "MIT", "optional": true diff --git a/package.json b/package.json index 2c9c57deaa6..0131542206f 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@angular-devkit/build-angular": "20.3.12", "@angular-eslint/schematics": "20.7.0", "@angular/cli": "20.3.12", - "@angular/compiler-cli": "20.3.15", + "@angular/compiler-cli": "20.3.16", "@babel/core": "7.28.5", "@babel/preset-env": "7.28.5", "@compodoc/compodoc": "1.1.32", @@ -74,10 +74,10 @@ "@types/koa__multer": "2.0.7", "@types/koa__router": "12.0.4", "@types/koa-bodyparser": "4.3.7", - "@types/koa-json": "2.0.23", + "@types/koa-json": "2.0.24", "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", - "@types/node": "22.19.3", + "@types/node": "22.19.7", "@types/node-fetch": "2.6.13", "@types/node-forge": "1.3.14", "@types/papaparse": "5.5.0", @@ -95,7 +95,7 @@ "base64-loader": "1.0.0", "browserslist": "4.28.1", "chromatic": "13.3.4", - "concurrently": "9.2.0", + "concurrently": "9.2.1", "copy-webpack-plugin": "13.0.1", "cross-env": "10.1.0", "css-loader": "7.1.2", @@ -155,15 +155,15 @@ "webpack-node-externals": "3.0.0" }, "dependencies": { - "@angular/animations": "20.3.15", + "@angular/animations": "20.3.16", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.15", - "@angular/compiler": "20.3.15", - "@angular/core": "20.3.15", - "@angular/forms": "20.3.15", - "@angular/platform-browser": "20.3.15", - "@angular/platform-browser-dynamic": "20.3.15", - "@angular/router": "20.3.15", + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/forms": "20.3.16", + "@angular/platform-browser": "20.3.16", + "@angular/platform-browser-dynamic": "20.3.16", + "@angular/router": "20.3.16", "@bitwarden/sdk-internal": "0.2.0-main.470", "@bitwarden/commercial-sdk-internal": "0.2.0-main.470", "@electron/fuses": "1.8.0",