diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index df099efa1d6..e2514433942 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -81,7 +81,9 @@ bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev apps/browser/src/platform @bitwarden/team-platform-dev apps/cli/src/platform @bitwarden/team-platform-dev apps/desktop/macos @bitwarden/team-platform-dev +apps/desktop/scripts @bitwarden/team-platform-dev apps/desktop/src/platform @bitwarden/team-platform-dev +apps/desktop/resources @bitwarden/team-platform-dev apps/web/src/app/platform @bitwarden/team-platform-dev libs/angular/src/platform @bitwarden/team-platform-dev libs/common/src/platform @bitwarden/team-platform-dev @@ -140,6 +142,7 @@ libs/components @bitwarden/team-ui-foundation libs/ui @bitwarden/team-ui-foundation apps/browser/src/platform/popup/layout @bitwarden/team-ui-foundation apps/browser/src/popup/app-routing.animations.ts @bitwarden/team-ui-foundation +apps/browser/src/popup/components/extension-anon-layout-wrapper @bitwarden/team-ui-foundation apps/web/src/app/layouts @bitwarden/team-ui-foundation diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 8ab74adf543..d91e0a12afd 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -36,8 +36,7 @@ on: description: "New version override (leave blank for automatic calculation, example: '2024.1.0')" required: false type: string - - +permissions: {} jobs: setup: name: Setup @@ -57,51 +56,11 @@ jobs: fi echo "branch=$BRANCH" >> $GITHUB_OUTPUT - - - cut_branch: - name: Cut branch - if: ${{ needs.setup.outputs.branch == 'rc' }} - needs: setup - runs-on: ubuntu-24.04 - steps: - - name: Generate GH App token - uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 - id: app-token - with: - app-id: ${{ secrets.BW_GHAPP_ID }} - private-key: ${{ secrets.BW_GHAPP_KEY }} - - - name: Check out target ref - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ inputs.target_ref }} - token: ${{ steps.app-token.outputs.token }} - - - name: Check if ${{ needs.setup.outputs.branch }} branch exists - env: - BRANCH_NAME: ${{ needs.setup.outputs.branch }} - run: | - if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then - echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY - exit 1 - fi - - - name: Cut branch - env: - BRANCH_NAME: ${{ needs.setup.outputs.branch }} - run: | - git switch --quiet --create $BRANCH_NAME - git push --quiet --set-upstream origin $BRANCH_NAME - - bump_version: name: Bump Version if: ${{ always() }} runs-on: ubuntu-24.04 - needs: - - cut_branch - - setup + needs: setup outputs: version_browser: ${{ steps.set-final-version-output.outputs.version_browser }} version_cli: ${{ steps.set-final-version-output.outputs.version_cli }} @@ -441,15 +400,13 @@ jobs: - name: Push changes if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} run: git push - - - cherry_pick: - name: Cherry-Pick Commit(s) + cut_branch: + name: Cut branch if: ${{ needs.setup.outputs.branch == 'rc' }} - runs-on: ubuntu-24.04 needs: - - bump_version - setup + - bump_version + runs-on: ubuntu-24.04 steps: - name: Generate GH App token uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 @@ -458,43 +415,24 @@ jobs: app-id: ${{ secrets.BW_GHAPP_ID }} private-key: ${{ secrets.BW_GHAPP_KEY }} - - name: Check out main branch + - name: Check out target ref uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - fetch-depth: 0 - ref: main + ref: ${{ inputs.target_ref }} token: ${{ steps.app-token.outputs.token }} - - name: Configure Git + - name: Check if ${{ needs.setup.outputs.branch }} branch exists + env: + BRANCH_NAME: ${{ needs.setup.outputs.branch }} run: | - git config --local user.email "actions@github.com" - git config --local user.name "Github Actions" + if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then + echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY + exit 1 + fi - - name: Perform cherry-pick(s) + - name: Cut branch + env: + BRANCH_NAME: ${{ needs.setup.outputs.branch }} run: | - # Function for cherry-picking - cherry_pick () { - local package_path="apps/$1/package.json" - local source_branch=$2 - local destination_branch=$3 - - # Get project commit/version from source branch - git switch $source_branch - SOURCE_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 $package_path) - SOURCE_VERSION=$(cat $package_path | jq -r '.version') - - # Get project commit/version from destination branch - git switch $destination_branch - DESTINATION_VERSION=$(cat $package_path | jq -r '.version') - - if [[ "$DESTINATION_VERSION" != "$SOURCE_VERSION" ]]; then - git cherry-pick --strategy-option=theirs -x $SOURCE_COMMIT - git push -u origin $destination_branch - fi - } - - # Cherry-pick from 'main' into 'rc' - cherry_pick browser main rc - cherry_pick cli main rc - cherry_pick desktop main rc - cherry_pick web main rc + git switch --quiet --create $BRANCH_NAME + git push --quiet --set-upstream origin $BRANCH_NAME \ No newline at end of file diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 3a8c7f14bc0..2d29efcc89e 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1922,6 +1922,9 @@ "typeSshKey": { "message": "SSH key" }, + "typeNote": { + "message": "Note" + }, "newItemHeader": { "message": "New $TYPE$", "placeholders": { @@ -5062,6 +5065,9 @@ "unlockPinSet": { "message": "Unlock PIN set" }, + "unlockBiometricSet": { + "message": "Unlock biometrics set" + }, "authenticating": { "message": "Authenticating" }, @@ -5403,5 +5409,9 @@ }, "noPermissionsViewPage": { "message": "You do not have permissions to view this page. Try logging in with a different account." + }, + "wasmNotSupported": { + "message": "WebAssembly is not supported on your browser or is not enabled. WebAssembly is required to use the Bitwarden app.", + "description": "'WebAssembly' is a technical term and should not be translated." } } diff --git a/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts b/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts index 6d1f0571ae7..bd85ff9293e 100644 --- a/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts +++ b/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts @@ -16,7 +16,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service"; -import { ExtensionAnonLayoutWrapperDataService } from "../extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; +import { ExtensionAnonLayoutWrapperDataService } from "../../../popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; import { ExtensionLoginComponentService } from "./extension-login-component.service"; diff --git a/apps/browser/src/auth/popup/login/extension-login-component.service.ts b/apps/browser/src/auth/popup/login/extension-login-component.service.ts index 49ed0635b7a..37d74616391 100644 --- a/apps/browser/src/auth/popup/login/extension-login-component.service.ts +++ b/apps/browser/src/auth/popup/login/extension-login-component.service.ts @@ -11,7 +11,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; -import { ExtensionAnonLayoutWrapperDataService } from "../extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; +import { ExtensionAnonLayoutWrapperDataService } from "../../../popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; @Injectable() export class ExtensionLoginComponentService 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 1fc4650b6f5..19f2d94e451 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -534,6 +534,11 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { if (!successful) { await this.biometricStateService.setFingerprintValidated(false); } + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("unlockBiometricSet"), + }); } catch (error) { this.form.controls.biometric.setValue(false); this.validationService.showError(error); diff --git a/apps/browser/src/autofill/fido2/utils/webauthn-utils.ts b/apps/browser/src/autofill/fido2/utils/webauthn-utils.ts index c8bcf5faa4b..0cccd91876d 100644 --- a/apps/browser/src/autofill/fido2/utils/webauthn-utils.ts +++ b/apps/browser/src/autofill/fido2/utils/webauthn-utils.ts @@ -88,6 +88,7 @@ export class WebauthnUtils { getClientExtensionResults: () => ({ credProps: result.extensions.credProps, }), + toJSON: () => Fido2Utils.createResultToJson(result), } as PublicKeyCredential; // Modify prototype chains to fix `instanceof` calls. @@ -134,6 +135,7 @@ export class WebauthnUtils { } as AuthenticatorAssertionResponse, getClientExtensionResults: () => ({}), authenticatorAttachment: "platform", + toJSON: () => Fido2Utils.getResultToJson(result), } as PublicKeyCredential; // Modify prototype chains to fix `instanceof` calls. diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index 2b58c32c926..852b79cad1d 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -45,6 +45,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CardComponent, CheckboxModule, @@ -58,7 +59,6 @@ import { SelectModule, TypographyModule, } from "@bitwarden/components"; -import { RestrictedItemTypesService } from "@bitwarden/vault"; import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service"; import { BrowserApi } from "../../../platform/browser/browser-api"; 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 a31a0b311db..677f58dee11 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 @@ -1,7 +1,3 @@ -// FIXME (PM-22628): angular imports are forbidden in background -// eslint-disable-next-line no-restricted-imports -import { Injectable } from "@angular/core"; - import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -20,7 +16,6 @@ import { import { NativeMessagingBackground } from "../../background/nativeMessaging.background"; import { BrowserApi } from "../../platform/browser/browser-api"; -@Injectable() export class BackgroundBrowserBiometricsService extends BiometricsService { constructor( private nativeMessagingBackground: () => NativeMessagingBackground, diff --git a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts index 5fc508ac2a6..bac435e2e8d 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts @@ -105,9 +105,11 @@ export class PopupRouterCacheService { * Navigate back in history */ async back() { - await this.state.update((prevState) => (prevState ? prevState.slice(0, -1) : [])); + const history = await this.state.update((prevState) => + prevState ? prevState.slice(0, -1) : [], + ); - if (this.hasNavigated) { + if (this.hasNavigated && history.length) { this.location.back(); return; } diff --git a/apps/browser/src/platform/services/sdk/browser-sdk-load.service.ts b/apps/browser/src/platform/services/sdk/browser-sdk-load.service.ts index 409ff0dea06..3ad6dc2583d 100644 --- a/apps/browser/src/platform/services/sdk/browser-sdk-load.service.ts +++ b/apps/browser/src/platform/services/sdk/browser-sdk-load.service.ts @@ -35,9 +35,9 @@ if (BrowserApi.isManifestVersion(3)) { console.info("WebAssembly is supported in this environment"); loadingPromise = import("./wasm"); } else { - // eslint-disable-next-line no-console - console.info("WebAssembly is not supported in this environment"); - loadingPromise = import("./fallback"); + loadingPromise = new Promise((_, reject) => { + reject(new Error("WebAssembly is not supported in this environment")); + }); } } @@ -51,9 +51,7 @@ async function importModule(): Promise { console.info("WebAssembly is supported in this environment"); await import("./wasm"); } else { - // eslint-disable-next-line no-console - console.info("WebAssembly is not supported in this environment"); - await import("./fallback"); + throw new Error("WebAssembly is not supported in this environment"); } // the wasm and fallback imports mutate globalThis to add the initSdk function diff --git a/apps/browser/src/platform/services/sdk/fallback.ts b/apps/browser/src/platform/services/sdk/fallback.ts deleted file mode 100644 index cee3598feda..00000000000 --- a/apps/browser/src/platform/services/sdk/fallback.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as sdk from "@bitwarden/sdk-internal"; -import * as wasm from "@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm.js"; - -import { GlobalWithWasmInit } from "./browser-sdk-load.service"; - -(globalThis as GlobalWithWasmInit).initSdk = () => { - (sdk as any).init(wasm); -}; diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index b530a868b61..fbf4afaf14a 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -16,10 +16,7 @@ import { unauthGuardFn, } from "@bitwarden/angular/auth/guards"; import { - AnonLayoutWrapperComponent, - AnonLayoutWrapperData, DevicesIcon, - LockIcon, LoginComponent, LoginDecryptionOptionsComponent, LoginSecondaryContentComponent, @@ -41,13 +38,10 @@ import { UserLockIcon, VaultIcon, } from "@bitwarden/auth/angular"; +import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, Icons } from "@bitwarden/components"; import { LockComponent } from "@bitwarden/key-management-ui"; import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; -import { - ExtensionAnonLayoutWrapperComponent, - ExtensionAnonLayoutWrapperData, -} from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component"; import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; @@ -89,6 +83,10 @@ import { TrashComponent } from "../vault/popup/settings/trash.component"; import { VaultSettingsV2Component } from "../vault/popup/settings/vault-settings-v2.component"; import { RouteElevation } from "./app-routing.animations"; +import { + ExtensionAnonLayoutWrapperComponent, + ExtensionAnonLayoutWrapperData, +} from "./components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component"; import { debounceNavigationGuard } from "./services/debounce-navigation.service"; import { TabsV2Component } from "./tabs-v2.component"; @@ -504,7 +502,7 @@ const routes: Routes = [ path: "lock", canActivate: [lockGuard()], data: { - pageIcon: LockIcon, + pageIcon: Icons.LockIcon, pageTitle: { key: "yourVaultIsLockedV2", }, diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index b6d3615af94..6a26476de43 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -11,7 +11,17 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; -import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap, map } from "rxjs"; +import { + Subject, + takeUntil, + firstValueFrom, + concatMap, + filter, + tap, + catchError, + of, + map, +} from "rxjs"; import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; @@ -23,6 +33,7 @@ import { AnimationControlService } from "@bitwarden/common/platform/abstractions import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { UserId } from "@bitwarden/common/types/guid"; @@ -48,23 +59,45 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn styles: [], animations: [routerTransition], template: ` -
- -
- + @if (showSdkWarning | async) { +
+ + {{ "wasmNotSupported" | i18n }} + + {{ "learnMore" | i18n }} + + +
+ } @else { +
+ +
+ + } `, standalone: false, }) export class AppComponent implements OnInit, OnDestroy { private compactModeService = inject(PopupCompactModeService); + private sdkService = inject(SdkService); private lastActivity: Date; private activeUserId: UserId; - private recordActivitySubject = new Subject(); private routerAnimations = false; private destroy$ = new Subject(); + // Show a warning if the SDK is not available. + protected showSdkWarning = this.sdkService.client$.pipe( + map(() => false), + catchError(() => of(true)), + ); + constructor( private authService: AuthService, private i18nService: I18nService, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 8bea41da4d6..77c87838ff7 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -20,11 +20,12 @@ import { ButtonModule, FormFieldModule, ToastModule, + CalloutModule, + LinkModule, } from "@bitwarden/components"; import { AccountComponent } from "../auth/popup/account-switching/account.component"; import { CurrentAccountComponent } from "../auth/popup/account-switching/current-account.component"; -import { ExtensionAnonLayoutWrapperComponent } from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component"; @@ -42,6 +43,7 @@ import { FilePopoutCalloutComponent } from "../tools/popup/components/file-popou import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; +import { ExtensionAnonLayoutWrapperComponent } from "./components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component"; import { UserVerificationComponent } from "./components/user-verification.component"; import { ServicesModule } from "./services/services.module"; import { TabsV2Component } from "./tabs-v2.component"; @@ -87,6 +89,8 @@ import "../platform/popup/locales"; CurrentAccountComponent, FormFieldModule, ExtensionAnonLayoutWrapperComponent, + CalloutModule, + LinkModule, ], declarations: [ AppComponent, diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts similarity index 95% rename from apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts rename to apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts index 1b844d4b2c7..952c42b8367 100644 --- a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts @@ -3,7 +3,7 @@ import { Observable, Subject } from "rxjs"; import { AnonLayoutWrapperDataService, DefaultAnonLayoutWrapperDataService, -} from "@bitwarden/auth/angular"; +} from "@bitwarden/components"; import { ExtensionAnonLayoutWrapperData } from "./extension-anon-layout-wrapper.component"; diff --git a/apps/browser/src/auth/popup/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 similarity index 100% rename from apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html rename to apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html diff --git a/apps/browser/src/auth/popup/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 similarity index 94% rename from apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts rename to apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts index b335155d355..fc2b6590992 100644 --- a/apps/browser/src/auth/popup/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,22 +5,23 @@ 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { + Icon, + Icons, + IconModule, + Translation, AnonLayoutComponent, AnonLayoutWrapperData, AnonLayoutWrapperDataService, -} from "@bitwarden/auth/angular"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Icon, IconModule, Translation } from "@bitwarden/components"; +} from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; +import { AccountSwitcherService } from "../../../auth/popup/account-switching/services/account-switcher.service"; 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"; -import { CurrentAccountComponent } from "../account-switching/current-account.component"; -import { AccountSwitcherService } from "../account-switching/services/account-switcher.service"; - -import { ExtensionBitwardenLogo } from "./extension-bitwarden-logo.icon"; export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData { showAcctSwitcher?: boolean; @@ -61,7 +62,7 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { protected hideFooter: boolean; protected theme: string; - protected logo = ExtensionBitwardenLogo; + protected logo = Icons.ExtensionBitwardenLogo; constructor( private router: Router, diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts similarity index 94% rename from apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts rename to apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts index 78ca577a69d..2c3d09b79fb 100644 --- a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -9,7 +9,6 @@ import { } from "@storybook/angular"; import { of } from "rxjs"; -import { AnonLayoutWrapperDataService, LockIcon } from "@bitwarden/auth/angular"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; @@ -23,13 +22,15 @@ import { import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { UserId } from "@bitwarden/common/types/guid"; -import { ButtonModule, I18nMockService } from "@bitwarden/components"; +import { + AnonLayoutWrapperDataService, + ButtonModule, + Icons, + I18nMockService, +} from "@bitwarden/components"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { RegistrationCheckEmailIcon } from "../../../../../../libs/auth/src/angular/icons/registration-check-email.icon"; +import { AccountSwitcherService } from "../../../auth/popup/account-switching/services/account-switcher.service"; import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service"; -import { AccountSwitcherService } from "../account-switching/services/account-switcher.service"; import { ExtensionAnonLayoutWrapperDataService } from "./extension-anon-layout-wrapper-data.service"; import { @@ -38,7 +39,7 @@ import { } from "./extension-anon-layout-wrapper.component"; export default { - title: "Auth/Extension Anon Layout Wrapper", + title: "Browser/Extension Anon Layout Wrapper", component: ExtensionAnonLayoutWrapperComponent, } as Meta; @@ -142,6 +143,8 @@ const decorators = (options: { switchAccounts: "Switch accounts", back: "Back", activeAccount: "Active account", + appLogoLabel: "app logo label", + bitwardenAccount: "Bitwarden Account", }); }, }, @@ -241,7 +244,7 @@ const initialData: ExtensionAnonLayoutWrapperData = { pageSubtitle: { key: "finishCreatingYourAccountBySettingAPassword", }, - pageIcon: LockIcon, + pageIcon: Icons.LockIcon, showAcctSwitcher: true, showBackButton: true, showLogo: true, @@ -255,7 +258,7 @@ const changedData: ExtensionAnonLayoutWrapperData = { pageSubtitle: { key: "checkYourEmail", }, - pageIcon: RegistrationCheckEmailIcon, + pageIcon: Icons.RegistrationCheckEmailIcon, showAcctSwitcher: false, showBackButton: false, showLogo: false, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 00e493fc035..9f79cf42553 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -22,7 +22,6 @@ import { } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { - AnonLayoutWrapperDataService, LoginComponentService, TwoFactorAuthComponentService, TwoFactorAuthEmailComponentService, @@ -121,7 +120,12 @@ import { } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; -import { CompactModeService, DialogService, ToastService } from "@bitwarden/components"; +import { + AnonLayoutWrapperDataService, + CompactModeService, + DialogService, + ToastService, +} from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { BiometricsService, @@ -138,7 +142,6 @@ import { import { AccountSwitcherService } from "../../auth/popup/account-switching/services/account-switcher.service"; import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service"; -import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; import { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service"; import { ExtensionSsoComponentService } from "../../auth/popup/login/extension-sso-component.service"; import { ExtensionLogoutService } from "../../auth/popup/logout/extension-logout.service"; @@ -181,6 +184,7 @@ import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-u import { Fido2UserVerificationService } from "../../vault/services/fido2-user-verification.service"; import { VaultBrowserStateService } from "../../vault/services/vault-browser-state.service"; import { VaultFilterService } from "../../vault/services/vault-filter.service"; +import { ExtensionAnonLayoutWrapperDataService } from "../components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; import { DebounceNavigationService } from "./debounce-navigation.service"; import { InitService } from "./init.service"; diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts index cc97027c82e..7e3db27640e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts @@ -12,8 +12,11 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { + RestrictedCipherType, + RestrictedItemTypesService, +} from "@bitwarden/common/vault/services/restricted-item-types.service"; import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components"; -import { RestrictedCipherType, RestrictedItemTypesService } from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts index caffd5e7119..fd7a0c4672b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts @@ -8,9 +8,10 @@ import { map, Observable } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CipherMenuItem, CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components"; -import { AddEditFolderDialogComponent, RestrictedItemTypesService } from "@bitwarden/vault"; +import { AddEditFolderDialogComponent } from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index f8351fe0f61..8b2786fab77 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -20,7 +20,10 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; -import { RestrictedCipherType, RestrictedItemTypesService } from "@bitwarden/vault"; +import { + RestrictedCipherType, + RestrictedItemTypesService, +} from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CachedFilterState, diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index a3e5fc4c2bd..9f7363afd7e 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -39,9 +39,12 @@ import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; +import { + isCipherViewRestricted, + RestrictedItemTypesService, +} from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; import { ChipSelectOption } from "@bitwarden/components"; -import { RestrictedItemTypesService } from "@bitwarden/vault"; const FILTER_VISIBILITY_KEY = new KeyDefinition(VAULT_SETTINGS_DISK, "filterVisibility", { deserializer: (obj) => obj, @@ -227,18 +230,8 @@ export class VaultPopupListFiltersService { } // Check if cipher type is restricted (with organization exemptions) - if (restrictions && restrictions.length > 0) { - const isRestricted = restrictions.some( - (restrictedType) => - restrictedType.cipherType === cipher.type && - (cipher.organizationId - ? !restrictedType.allowViewOrgIds.includes(cipher.organizationId) - : restrictedType.allowViewOrgIds.length === 0), - ); - - if (isRestricted) { - return false; - } + if (isCipherViewRestricted(cipher, restrictions)) { + return false; } if (filters.cipherType !== null && cipher.type !== filters.cipherType) { diff --git a/apps/desktop/scripts/after-pack.js b/apps/desktop/scripts/after-pack.js index 99c3d91be52..cdb5e098440 100644 --- a/apps/desktop/scripts/after-pack.js +++ b/apps/desktop/scripts/after-pack.js @@ -89,7 +89,7 @@ async function run(context) { } else { // For non-Appstore builds, we don't need the inherit binary as they are not sandboxed, // but we sign and include it anyway for consistency. It should be removed once DDG supports the proxy directly. - const entitlementsName = "entitlements.mac.plist"; + const entitlementsName = "entitlements.mac.inherit.plist"; const entitlementsPath = path.join(__dirname, "..", "resources", entitlementsName); child_process.execSync( `codesign -s '${id}' -i ${packageId} -f --timestamp --options runtime --entitlements ${entitlementsPath} ${proxyPath}`, diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 50036fb964c..d90f3cf0d26 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -16,11 +16,8 @@ import { } from "@bitwarden/angular/auth/guards"; import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { - AnonLayoutWrapperComponent, - AnonLayoutWrapperData, LoginComponent, LoginSecondaryContentComponent, - LockIcon, LoginViaAuthRequestComponent, PasswordHintComponent, RegistrationFinishComponent, @@ -42,6 +39,7 @@ import { DeviceVerificationIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, Icons } from "@bitwarden/components"; import { LockComponent } from "@bitwarden/key-management-ui"; import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; @@ -292,7 +290,7 @@ const routes: Routes = [ path: "lock", canActivate: [lockGuard()], data: { - pageIcon: LockIcon, + pageIcon: Icons.LockIcon, pageTitle: { key: "yourVaultIsLockedV2", }, diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index 9b2472106dd..58c3e10e334 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -9,6 +9,7 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe"; import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe"; import { CalloutModule, DialogModule } from "@bitwarden/components"; +import { AssignCollectionsComponent } from "@bitwarden/vault"; import { DeleteAccountComponent } from "../auth/delete-account.component"; import { LoginModule } from "../auth/login/login.module"; @@ -55,6 +56,7 @@ import { SharedModule } from "./shared/shared.module"; DeleteAccountComponent, UserVerificationComponent, NavComponent, + AssignCollectionsComponent, VaultV2Component, ], declarations: [ diff --git a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts index 6415443bfbc..97e1d322a0e 100644 --- a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts @@ -1,3 +1,4 @@ +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; import { BiometricsService } from "@bitwarden/key-management"; @@ -6,10 +7,10 @@ import { BiometricsService } from "@bitwarden/key-management"; * specifically for the main process. */ export abstract class DesktopBiometricsService extends BiometricsService { - abstract setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise; + abstract setBiometricProtectedUnlockKeyForUser( + userId: UserId, + value: SymmetricCryptoKey, + ): Promise; abstract deleteBiometricUnlockKeyForUser(userId: UserId): Promise; - abstract setupBiometrics(): Promise; - - abstract setClientKeyHalfForUser(userId: UserId, value: string | null): Promise; } diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts b/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts index fe40aad54d9..e270c4cc50f 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts @@ -1,5 +1,6 @@ import { ipcMain } from "electron"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -37,17 +38,12 @@ export class MainBiometricsIPCListener { } return await this.biometricService.setBiometricProtectedUnlockKeyForUser( message.userId as UserId, - message.key, + SymmetricCryptoKey.fromString(message.key), ); case BiometricAction.RemoveKeyForUser: return await this.biometricService.deleteBiometricUnlockKeyForUser( message.userId as UserId, ); - case BiometricAction.SetClientKeyHalf: - return await this.biometricService.setClientKeyHalfForUser( - message.userId as UserId, - message.key, - ); case BiometricAction.Setup: return await this.biometricService.setupBiometrics(); diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts index 09a4dcef4b3..213f3d48a98 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts @@ -1,10 +1,10 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; import { BiometricsService, @@ -13,6 +13,7 @@ import { } from "@bitwarden/key-management"; import { WindowMain } from "../../main/window.main"; +import { MainCryptoFunctionService } from "../../platform/main/main-crypto-function.service"; import { MainBiometricsService } from "./main-biometrics.service"; import OsBiometricsServiceLinux from "./os-biometrics-linux.service"; @@ -27,21 +28,25 @@ jest.mock("@bitwarden/desktop-napi", () => { }; }); +const unlockKey = new SymmetricCryptoKey(new Uint8Array(64)); + describe("MainBiometricsService", function () { const i18nService = mock(); const windowMain = mock(); const logService = mock(); - const messagingService = mock(); const biometricStateService = mock(); + const cryptoFunctionService = mock(); + const encryptService = mock(); it("Should call the platformspecific methods", async () => { const sut = new MainBiometricsService( i18nService, windowMain, logService, - messagingService, process.platform, biometricStateService, + encryptService, + cryptoFunctionService, ); const mockService = mock(); @@ -57,9 +62,10 @@ describe("MainBiometricsService", function () { i18nService, windowMain, logService, - messagingService, "win32", biometricStateService, + encryptService, + cryptoFunctionService, ); const internalService = (sut as any).osBiometricsService; @@ -72,9 +78,10 @@ describe("MainBiometricsService", function () { i18nService, windowMain, logService, - messagingService, "darwin", biometricStateService, + encryptService, + cryptoFunctionService, ); const internalService = (sut as any).osBiometricsService; expect(internalService).not.toBeNull(); @@ -86,9 +93,10 @@ describe("MainBiometricsService", function () { i18nService, windowMain, logService, - messagingService, "linux", biometricStateService, + encryptService, + cryptoFunctionService, ); const internalService = (sut as any).osBiometricsService; @@ -106,9 +114,10 @@ describe("MainBiometricsService", function () { i18nService, windowMain, logService, - messagingService, process.platform, biometricStateService, + encryptService, + cryptoFunctionService, ); innerService = mock(); @@ -131,9 +140,9 @@ describe("MainBiometricsService", function () { ]; for (const [supportsBiometric, needsSetup, canAutoSetup, expected] of testCases) { - innerService.osSupportsBiometric.mockResolvedValue(supportsBiometric as boolean); - innerService.osBiometricsNeedsSetup.mockResolvedValue(needsSetup as boolean); - innerService.osBiometricsCanAutoSetup.mockResolvedValue(canAutoSetup as boolean); + innerService.supportsBiometrics.mockResolvedValue(supportsBiometric as boolean); + innerService.needsSetup.mockResolvedValue(needsSetup as boolean); + innerService.canAutoSetup.mockResolvedValue(canAutoSetup as boolean); const actual = await sut.getBiometricsStatus(); expect(actual).toBe(expected); @@ -175,12 +184,23 @@ describe("MainBiometricsService", function () { biometricStateService.getRequirePasswordOnStart.mockResolvedValue( requirePasswordOnStart as boolean, ); - (sut as any).clientKeyHalves = new Map(); - const userId = "test" as UserId; - if (hasKeyHalf) { - (sut as any).clientKeyHalves.set(userId, "test"); + if (!requirePasswordOnStart) { + (sut as any).osBiometricsService.getBiometricsFirstUnlockStatusForUser = jest + .fn() + .mockResolvedValue(BiometricsStatus.Available); + } else { + if (hasKeyHalf) { + (sut as any).osBiometricsService.getBiometricsFirstUnlockStatusForUser = jest + .fn() + .mockResolvedValue(BiometricsStatus.Available); + } else { + (sut as any).osBiometricsService.getBiometricsFirstUnlockStatusForUser = jest + .fn() + .mockResolvedValue(BiometricsStatus.UnlockNeeded); + } } + const userId = "test" as UserId; const actual = await sut.getBiometricsStatusForUser(userId); expect(actual).toBe(expected); } @@ -193,50 +213,17 @@ describe("MainBiometricsService", function () { i18nService, windowMain, logService, - messagingService, process.platform, biometricStateService, + encryptService, + cryptoFunctionService, ); const osBiometricsService = mock(); (sut as any).osBiometricsService = osBiometricsService; await sut.setupBiometrics(); - expect(osBiometricsService.osBiometricsSetup).toHaveBeenCalled(); - }); - }); - - describe("setClientKeyHalfForUser", () => { - let sut: MainBiometricsService; - - beforeEach(() => { - sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - messagingService, - process.platform, - biometricStateService, - ); - }); - - it("should set the client key half for the user", async () => { - const userId = "test" as UserId; - const keyHalf = "testKeyHalf"; - - await sut.setClientKeyHalfForUser(userId, keyHalf); - - expect((sut as any).clientKeyHalves.has(userId)).toBe(true); - expect((sut as any).clientKeyHalves.get(userId)).toBe(keyHalf); - }); - - it("should reset the client key half for the user", async () => { - const userId = "test" as UserId; - - await sut.setClientKeyHalfForUser(userId, null); - - expect((sut as any).clientKeyHalves.has(userId)).toBe(true); - expect((sut as any).clientKeyHalves.get(userId)).toBe(null); + expect(osBiometricsService.runSetup).toHaveBeenCalled(); }); }); @@ -246,9 +233,10 @@ describe("MainBiometricsService", function () { i18nService, windowMain, logService, - messagingService, process.platform, biometricStateService, + encryptService, + cryptoFunctionService, ); const osBiometricsService = mock(); (sut as any).osBiometricsService = osBiometricsService; @@ -268,9 +256,10 @@ describe("MainBiometricsService", function () { i18nService, windowMain, logService, - messagingService, process.platform, biometricStateService, + encryptService, + cryptoFunctionService, ); osBiometricsService = mock(); (sut as any).osBiometricsService = osBiometricsService; @@ -278,34 +267,24 @@ describe("MainBiometricsService", function () { it("should return null if no biometric key is returned ", async () => { const userId = "test" as UserId; - (sut as any).clientKeyHalves.set(userId, "testKeyHalf"); - + osBiometricsService.getBiometricKey.mockResolvedValue(null); const userKey = await sut.unlockWithBiometricsForUser(userId); expect(userKey).toBeNull(); - expect(osBiometricsService.getBiometricKey).toHaveBeenCalledWith( - "Bitwarden_biometric", - `${userId}_user_biometric`, - "testKeyHalf", - ); + expect(osBiometricsService.getBiometricKey).toHaveBeenCalledWith(userId); }); it("should return the biometric key if a valid key is returned", async () => { const userId = "test" as UserId; - (sut as any).clientKeyHalves.set(userId, "testKeyHalf"); - const biometricKey = Utils.fromBufferToB64(new Uint8Array(64)); + const biometricKey = new SymmetricCryptoKey(new Uint8Array(64)); osBiometricsService.getBiometricKey.mockResolvedValue(biometricKey); const userKey = await sut.unlockWithBiometricsForUser(userId); expect(userKey).not.toBeNull(); - expect(userKey!.keyB64).toBe(biometricKey); + expect(userKey!.keyB64).toBe(biometricKey.toBase64()); expect(userKey!.inner().type).toBe(EncryptionType.AesCbc256_HmacSha256_B64); - expect(osBiometricsService.getBiometricKey).toHaveBeenCalledWith( - "Bitwarden_biometric", - `${userId}_user_biometric`, - "testKeyHalf", - ); + expect(osBiometricsService.getBiometricKey).toHaveBeenCalledWith(userId); }); }); @@ -318,37 +297,21 @@ describe("MainBiometricsService", function () { i18nService, windowMain, logService, - messagingService, process.platform, biometricStateService, + encryptService, + cryptoFunctionService, ); osBiometricsService = mock(); (sut as any).osBiometricsService = osBiometricsService; }); - it("should throw an error if no client key half is provided", async () => { - const userId = "test" as UserId; - const unlockKey = "testUnlockKey"; - - await expect(sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey)).rejects.toThrow( - "No client key half provided for user", - ); - }); - it("should call the platform specific setBiometricKey method", async () => { const userId = "test" as UserId; - const unlockKey = "testUnlockKey"; - - (sut as any).clientKeyHalves.set(userId, "testKeyHalf"); await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey); - expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith( - "Bitwarden_biometric", - `${userId}_user_biometric`, - unlockKey, - "testKeyHalf", - ); + expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey); }); }); @@ -358,9 +321,10 @@ describe("MainBiometricsService", function () { i18nService, windowMain, logService, - messagingService, process.platform, biometricStateService, + encryptService, + cryptoFunctionService, ); const osBiometricsService = mock(); (sut as any).osBiometricsService = osBiometricsService; @@ -369,10 +333,7 @@ describe("MainBiometricsService", function () { await sut.deleteBiometricUnlockKeyForUser(userId); - expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith( - "Bitwarden_biometric", - `${userId}_user_biometric`, - ); + expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId); }); }); @@ -384,9 +345,10 @@ describe("MainBiometricsService", function () { i18nService, windowMain, logService, - messagingService, process.platform, biometricStateService, + encryptService, + cryptoFunctionService, ); }); @@ -413,9 +375,10 @@ describe("MainBiometricsService", function () { i18nService, windowMain, logService, - messagingService, process.platform, biometricStateService, + encryptService, + cryptoFunctionService, ); const shouldAutoPrompt = await sut.getShouldAutopromptNow(); diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts index cf80fa5f7f3..a6a0e532655 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts @@ -1,6 +1,7 @@ +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; @@ -13,16 +14,16 @@ import { OsBiometricService } from "./os-biometrics.service"; export class MainBiometricsService extends DesktopBiometricsService { private osBiometricsService: OsBiometricService; - private clientKeyHalves = new Map(); private shouldAutoPrompt = true; constructor( private i18nService: I18nService, private windowMain: WindowMain, private logService: LogService, - private messagingService: MessagingService, - private platform: NodeJS.Platform, + platform: NodeJS.Platform, private biometricStateService: BiometricStateService, + private encryptService: EncryptService, + private cryptoFunctionService: CryptoFunctionService, ) { super(); if (platform === "win32") { @@ -32,6 +33,9 @@ export class MainBiometricsService extends DesktopBiometricsService { this.i18nService, this.windowMain, this.logService, + this.biometricStateService, + this.encryptService, + this.cryptoFunctionService, ); } else if (platform === "darwin") { // eslint-disable-next-line @@ -40,7 +44,11 @@ export class MainBiometricsService extends DesktopBiometricsService { } else if (platform === "linux") { // eslint-disable-next-line const OsBiometricsServiceLinux = require("./os-biometrics-linux.service").default; - this.osBiometricsService = new OsBiometricsServiceLinux(this.i18nService, this.windowMain); + this.osBiometricsService = new OsBiometricsServiceLinux( + this.biometricStateService, + this.encryptService, + this.cryptoFunctionService, + ); } else { throw new Error("Unsupported platform"); } @@ -55,11 +63,11 @@ export class MainBiometricsService extends DesktopBiometricsService { * @returns the status of the biometrics of the platform */ async getBiometricsStatus(): Promise { - if (!(await this.osBiometricsService.osSupportsBiometric())) { + if (!(await this.osBiometricsService.supportsBiometrics())) { return BiometricsStatus.HardwareUnavailable; } else { - if (await this.osBiometricsService.osBiometricsNeedsSetup()) { - if (await this.osBiometricsService.osBiometricsCanAutoSetup()) { + if (await this.osBiometricsService.needsSetup()) { + if (await this.osBiometricsService.canAutoSetup()) { return BiometricsStatus.AutoSetupNeeded; } else { return BiometricsStatus.ManualSetupNeeded; @@ -80,20 +88,12 @@ export class MainBiometricsService extends DesktopBiometricsService { if (!(await this.biometricStateService.getBiometricUnlockEnabled(userId))) { return BiometricsStatus.NotEnabledLocally; } - const platformStatus = await this.getBiometricsStatus(); if (!(platformStatus === BiometricsStatus.Available)) { return platformStatus; } - const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId); - const clientKeyHalfB64 = this.clientKeyHalves.get(userId); - const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64; - if (!clientKeyHalfSatisfied) { - return BiometricsStatus.UnlockNeeded; - } - - return BiometricsStatus.Available; + return await this.osBiometricsService.getBiometricsFirstUnlockStatusForUser(userId); } async authenticateBiometric(): Promise { @@ -101,11 +101,7 @@ export class MainBiometricsService extends DesktopBiometricsService { } async setupBiometrics(): Promise { - return await this.osBiometricsService.osBiometricsSetup(); - } - - async setClientKeyHalfForUser(userId: UserId, value: string | null): Promise { - this.clientKeyHalves.set(userId, value); + return await this.osBiometricsService.runSetup(); } async authenticateWithBiometrics(): Promise { @@ -113,43 +109,23 @@ export class MainBiometricsService extends DesktopBiometricsService { } async unlockWithBiometricsForUser(userId: UserId): Promise { - const biometricKey = await this.osBiometricsService.getBiometricKey( - "Bitwarden_biometric", - `${userId}_user_biometric`, - this.clientKeyHalves.get(userId) ?? undefined, - ); - if (biometricKey == null) { - return null; - } - - return SymmetricCryptoKey.fromString(biometricKey) as UserKey; + return (await this.osBiometricsService.getBiometricKey(userId)) as UserKey; } - async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise { - const service = "Bitwarden_biometric"; - const storageKey = `${userId}_user_biometric`; - if (!this.clientKeyHalves.has(userId)) { - throw new Error("No client key half provided for user"); - } - - return await this.osBiometricsService.setBiometricKey( - service, - storageKey, - value, - this.clientKeyHalves.get(userId) ?? undefined, - ); + async setBiometricProtectedUnlockKeyForUser( + userId: UserId, + key: SymmetricCryptoKey, + ): Promise { + return await this.osBiometricsService.setBiometricKey(userId, key); } async deleteBiometricUnlockKeyForUser(userId: UserId): Promise { - return await this.osBiometricsService.deleteBiometricKey( - "Bitwarden_biometric", - `${userId}_user_biometric`, - ); + return await this.osBiometricsService.deleteBiometricKey(userId); } /** * Set whether to auto-prompt the user for biometric unlock; this can be used to prevent auto-prompting being initiated by a process reload. - * Reasons for enabling auto prompt include: Starting the app, un-minimizing the app, manually account switching + * Reasons for enabling auto-prompt include: Starting the app, un-minimizing the app, manually account switching * @param value Whether to auto-prompt the user for biometric unlock */ async setShouldAutopromptNow(value: boolean): Promise { diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts index fb150f2a653..8d3c8e9795f 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts @@ -1,10 +1,14 @@ import { spawn } from "child_process"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; import { biometrics, passwords } from "@bitwarden/desktop-napi"; +import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management"; -import { WindowMain } from "../../main/window.main"; import { isFlatpak, isLinux, isSnapStore } from "../../utils"; import { OsBiometricService } from "./os-biometrics.service"; @@ -28,59 +32,62 @@ const polkitPolicy = ` const policyFileName = "com.bitwarden.Bitwarden.policy"; const policyPath = "/usr/share/polkit-1/actions/"; +const SERVICE = "Bitwarden_biometric"; +function getLookupKeyForUser(userId: UserId): string { + return `${userId}_user_biometric`; +} + export default class OsBiometricsServiceLinux implements OsBiometricService { constructor( - private i18nservice: I18nService, - private windowMain: WindowMain, + private biometricStateService: BiometricStateService, + private encryptService: EncryptService, + private cryptoFunctionService: CryptoFunctionService, ) {} private _iv: string | null = null; // Use getKeyMaterial helper instead of direct access private _osKeyHalf: string | null = null; + private clientKeyHalves = new Map(); - async setBiometricKey( - service: string, - key: string, - value: string, - clientKeyPartB64: string | undefined, - ): Promise { + async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { + const clientKeyPartB64 = Utils.fromBufferToB64( + await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key), + ); const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 }); await biometrics.setBiometricSecret( - service, - key, - value, + SERVICE, + getLookupKeyForUser(userId), + key.toBase64(), storageDetails.key_material, storageDetails.ivB64, ); } - async deleteBiometricKey(service: string, key: string): Promise { - await passwords.deletePassword(service, key); + async deleteBiometricKey(userId: UserId): Promise { + await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId)); } - async getBiometricKey( - service: string, - storageKey: string, - clientKeyPartB64: string | undefined, - ): Promise { + async getBiometricKey(userId: UserId): Promise { const success = await this.authenticateBiometric(); if (!success) { throw new Error("Biometric authentication failed"); } - const value = await passwords.getPassword(service, storageKey); + const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId)); if (value == null || value == "") { return null; } else { + const clientKeyHalf = this.clientKeyHalves.get(userId); + const clientKeyPartB64 = Utils.fromBufferToB64(clientKeyHalf); const encValue = new EncString(value); this.setIv(encValue.iv); const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 }); const storedValue = await biometrics.getBiometricSecret( - service, - storageKey, + SERVICE, + getLookupKeyForUser(userId), storageDetails.key_material, ); - return storedValue; + return SymmetricCryptoKey.fromString(storedValue); } } @@ -89,7 +96,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { return await biometrics.prompt(hwnd, ""); } - async osSupportsBiometric(): Promise { + async supportsBiometrics(): Promise { // We assume all linux distros have some polkit implementation // that either has bitwarden set up or not, which is reflected in osBiomtricsNeedsSetup. // Snap does not have access at the moment to polkit @@ -99,7 +106,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { return await passwords.isAvailable(); } - async osBiometricsNeedsSetup(): Promise { + async needsSetup(): Promise { if (isSnapStore()) { return false; } @@ -108,7 +115,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { return !(await biometrics.available()); } - async osBiometricsCanAutoSetup(): Promise { + async canAutoSetup(): Promise { // We cannot auto setup on snap or flatpak since the filesystem is sandboxed. // The user needs to manually set up the polkit policy outside of the sandbox // since we allow access to polkit via dbus for the sandboxed clients, the authentication works from @@ -116,7 +123,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { return isLinux() && !isSnapStore() && !isFlatpak(); } - async osBiometricsSetup(): Promise { + async runSetup(): Promise { const process = spawn("pkexec", [ "bash", "-c", @@ -165,4 +172,46 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { ivB64: this._iv, }; } + + private async getOrCreateBiometricEncryptionClientKeyHalf( + userId: UserId, + key: SymmetricCryptoKey, + ): Promise { + const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId); + if (!requireClientKeyHalf) { + return null; + } + + if (this.clientKeyHalves.has(userId)) { + return this.clientKeyHalves.get(userId) || null; + } + + // Retrieve existing key half if it exists + let clientKeyHalf: Uint8Array | null = null; + const encryptedClientKeyHalf = + await this.biometricStateService.getEncryptedClientKeyHalf(userId); + if (encryptedClientKeyHalf != null) { + clientKeyHalf = await this.encryptService.decryptBytes(encryptedClientKeyHalf, key); + } + if (clientKeyHalf == null) { + // Set a key half if it doesn't exist + const keyBytes = await this.cryptoFunctionService.randomBytes(32); + const encKey = await this.encryptService.encryptBytes(keyBytes, key); + await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId); + } + + this.clientKeyHalves.set(userId, clientKeyHalf); + + return clientKeyHalf; + } + + async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { + const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId); + const clientKeyHalfB64 = this.clientKeyHalves.get(userId); + const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64; + if (!clientKeyHalfSatisfied) { + return BiometricsStatus.UnlockNeeded; + } + return BiometricsStatus.Available; + } } diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts index e361084726a..004495b6da9 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts @@ -1,14 +1,22 @@ import { systemPreferences } from "electron"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; import { passwords } from "@bitwarden/desktop-napi"; +import { BiometricsStatus } from "@bitwarden/key-management"; import { OsBiometricService } from "./os-biometrics.service"; +const SERVICE = "Bitwarden_biometric"; +function getLookupKeyForUser(userId: UserId): string { + return `${userId}_user_biometric`; +} + export default class OsBiometricsServiceMac implements OsBiometricService { constructor(private i18nservice: I18nService) {} - async osSupportsBiometric(): Promise { + async supportsBiometrics(): Promise { return systemPreferences.canPromptTouchID(); } @@ -21,44 +29,52 @@ export default class OsBiometricsServiceMac implements OsBiometricService { } } - async getBiometricKey(service: string, key: string): Promise { + async getBiometricKey(userId: UserId): Promise { const success = await this.authenticateBiometric(); if (!success) { throw new Error("Biometric authentication failed"); } + const keyB64 = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId)); + if (keyB64 == null) { + return null; + } - return await passwords.getPassword(service, key); + return SymmetricCryptoKey.fromString(keyB64); } - async setBiometricKey(service: string, key: string, value: string): Promise { - if (await this.valueUpToDate(service, key, value)) { + async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { + if (await this.valueUpToDate(userId, key)) { return; } - return await passwords.setPassword(service, key, value); + return await passwords.setPassword(SERVICE, getLookupKeyForUser(userId), key.toBase64()); } - async deleteBiometricKey(service: string, key: string): Promise { - return await passwords.deletePassword(service, key); + async deleteBiometricKey(user: UserId): Promise { + return await passwords.deletePassword(SERVICE, getLookupKeyForUser(user)); } - private async valueUpToDate(service: string, key: string, value: string): Promise { + private async valueUpToDate(user: UserId, key: SymmetricCryptoKey): Promise { try { - const existing = await passwords.getPassword(service, key); - return existing === value; + const existing = await passwords.getPassword(SERVICE, getLookupKeyForUser(user)); + return existing === key.toBase64(); } catch { return false; } } - async osBiometricsNeedsSetup() { + async needsSetup() { return false; } - async osBiometricsCanAutoSetup(): Promise { + async canAutoSetup(): Promise { return false; } - async osBiometricsSetup(): Promise {} + async runSetup(): Promise {} + + async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { + return BiometricsStatus.Available; + } } diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.spec.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.spec.ts new file mode 100644 index 00000000000..d0fd8682f2a --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.spec.ts @@ -0,0 +1,143 @@ +import { mock } from "jest-mock-extended"; + +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management"; + +import OsBiometricsServiceWindows from "./os-biometrics-windows.service"; + +jest.mock("@bitwarden/desktop-napi", () => ({ + biometrics: { + available: jest.fn(), + setBiometricSecret: jest.fn(), + getBiometricSecret: jest.fn(), + deriveKeyMaterial: jest.fn(), + prompt: jest.fn(), + }, + passwords: { + getPassword: jest.fn(), + deletePassword: jest.fn(), + }, +})); + +describe("OsBiometricsServiceWindows", () => { + let service: OsBiometricsServiceWindows; + let biometricStateService: BiometricStateService; + + beforeEach(() => { + const i18nService = mock(); + const logService = mock(); + biometricStateService = mock(); + const encryptionService = mock(); + const cryptoFunctionService = mock(); + service = new OsBiometricsServiceWindows( + i18nService, + null, + logService, + biometricStateService, + encryptionService, + cryptoFunctionService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getBiometricsFirstUnlockStatusForUser", () => { + const userId = "test-user-id" as UserId; + it("should return Available when requirePasswordOnRestart is false", async () => { + biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(false); + const result = await service.getBiometricsFirstUnlockStatusForUser(userId); + expect(result).toBe(BiometricsStatus.Available); + }); + it("should return Available when requirePasswordOnRestart is true and client key half is set", async () => { + biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true); + (service as any).clientKeyHalves = new Map(); + (service as any).clientKeyHalves.set(userId, new Uint8Array([1, 2, 3, 4])); + const result = await service.getBiometricsFirstUnlockStatusForUser(userId); + expect(result).toBe(BiometricsStatus.Available); + }); + it("should return UnlockNeeded when requirePasswordOnRestart is true and client key half is not set", async () => { + biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true); + (service as any).clientKeyHalves = new Map(); + const result = await service.getBiometricsFirstUnlockStatusForUser(userId); + expect(result).toBe(BiometricsStatus.UnlockNeeded); + }); + }); + + describe("getOrCreateBiometricEncryptionClientKeyHalf", () => { + const userId = "test-user-id" as UserId; + const key = new SymmetricCryptoKey(new Uint8Array(64)); + let encryptionService: EncryptService; + let cryptoFunctionService: CryptoFunctionService; + + beforeEach(() => { + encryptionService = mock(); + cryptoFunctionService = mock(); + service = new OsBiometricsServiceWindows( + mock(), + null, + mock(), + biometricStateService, + encryptionService, + cryptoFunctionService, + ); + }); + + it("should return null if getRequirePasswordOnRestart is false", async () => { + biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(false); + const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); + expect(result).toBeNull(); + }); + + it("should return cached key half if already present", async () => { + biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true); + const cachedKeyHalf = new Uint8Array([10, 20, 30]); + (service as any).clientKeyHalves.set(userId.toString(), cachedKeyHalf); + const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); + expect(result).toBe(cachedKeyHalf); + }); + + it("should decrypt and return existing encrypted client key half", async () => { + biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true); + biometricStateService.getEncryptedClientKeyHalf = jest + .fn() + .mockResolvedValue(new Uint8Array([1, 2, 3])); + const decrypted = new Uint8Array([4, 5, 6]); + encryptionService.decryptBytes = jest.fn().mockResolvedValue(decrypted); + + const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); + + expect(biometricStateService.getEncryptedClientKeyHalf).toHaveBeenCalledWith(userId); + expect(encryptionService.decryptBytes).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]), key); + expect(result).toEqual(decrypted); + expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(decrypted); + }); + + it("should generate, encrypt, store, and cache a new key half if none exists", async () => { + biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true); + biometricStateService.getEncryptedClientKeyHalf = jest.fn().mockResolvedValue(null); + const randomBytes = new Uint8Array([7, 8, 9]); + cryptoFunctionService.randomBytes = jest.fn().mockResolvedValue(randomBytes); + const encrypted = new Uint8Array([10, 11, 12]); + encryptionService.encryptBytes = jest.fn().mockResolvedValue(encrypted); + biometricStateService.setEncryptedClientKeyHalf = jest.fn().mockResolvedValue(undefined); + + const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); + + expect(cryptoFunctionService.randomBytes).toHaveBeenCalledWith(32); + expect(encryptionService.encryptBytes).toHaveBeenCalledWith(randomBytes, key); + expect(biometricStateService.setEncryptedClientKeyHalf).toHaveBeenCalledWith( + encrypted, + userId, + ); + expect(result).toBeNull(); + expect((service as any).clientKeyHalves.get(userId.toString())).toBeNull(); + }); + }); +}); diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts index 53647549295..dc4f8674d7f 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts @@ -1,10 +1,14 @@ +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; import { biometrics, passwords } from "@bitwarden/desktop-napi"; +import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management"; import { WindowMain } from "../../main/window.main"; @@ -13,87 +17,107 @@ import { OsBiometricService } from "./os-biometrics.service"; const KEY_WITNESS_SUFFIX = "_witness"; const WITNESS_VALUE = "known key"; +const SERVICE = "Bitwarden_biometric"; +function getLookupKeyForUser(userId: UserId): string { + return `${userId}_user_biometric`; +} + export default class OsBiometricsServiceWindows implements OsBiometricService { // Use set helper method instead of direct access private _iv: string | null = null; // Use getKeyMaterial helper instead of direct access private _osKeyHalf: string | null = null; + private clientKeyHalves = new Map(); constructor( private i18nService: I18nService, private windowMain: WindowMain, private logService: LogService, + private biometricStateService: BiometricStateService, + private encryptService: EncryptService, + private cryptoFunctionService: CryptoFunctionService, ) {} - async osSupportsBiometric(): Promise { + async supportsBiometrics(): Promise { return await biometrics.available(); } - async getBiometricKey( - service: string, - storageKey: string, - clientKeyHalfB64: string, - ): Promise { - const value = await passwords.getPassword(service, storageKey); + async getBiometricKey(userId: UserId): Promise { + const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId)); + let clientKeyHalfB64: string | null = null; + if (this.clientKeyHalves.has(userId)) { + clientKeyHalfB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)); + } if (value == null || value == "") { return null; } else if (!EncString.isSerializedEncString(value)) { // Update to format encrypted with client key half const storageDetails = await this.getStorageDetails({ - clientKeyHalfB64, + clientKeyHalfB64: clientKeyHalfB64, }); await biometrics.setBiometricSecret( - service, - storageKey, + SERVICE, + getLookupKeyForUser(userId), value, storageDetails.key_material, storageDetails.ivB64, ); - return value; + return SymmetricCryptoKey.fromString(value); } else { const encValue = new EncString(value); this.setIv(encValue.iv); const storageDetails = await this.getStorageDetails({ - clientKeyHalfB64, + clientKeyHalfB64: clientKeyHalfB64, }); - return await biometrics.getBiometricSecret(service, storageKey, storageDetails.key_material); + return SymmetricCryptoKey.fromString( + await biometrics.getBiometricSecret( + SERVICE, + getLookupKeyForUser(userId), + storageDetails.key_material, + ), + ); } } - async setBiometricKey( - service: string, - storageKey: string, - value: string, - clientKeyPartB64: string | undefined, - ): Promise { - const parsedValue = SymmetricCryptoKey.fromString(value); - if (await this.valueUpToDate({ value: parsedValue, clientKeyPartB64, service, storageKey })) { + async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { + const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); + + if ( + await this.valueUpToDate({ + value: key, + clientKeyPartB64: Utils.fromBufferToB64(clientKeyHalf), + service: SERVICE, + storageKey: getLookupKeyForUser(userId), + }) + ) { return; } - const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 }); + const storageDetails = await this.getStorageDetails({ + clientKeyHalfB64: Utils.fromBufferToB64(clientKeyHalf), + }); const storedValue = await biometrics.setBiometricSecret( - service, - storageKey, - value, + SERVICE, + getLookupKeyForUser(userId), + key.toBase64(), storageDetails.key_material, storageDetails.ivB64, ); const parsedStoredValue = new EncString(storedValue); await this.storeValueWitness( - parsedValue, + key, parsedStoredValue, - service, - storageKey, - clientKeyPartB64, + SERVICE, + getLookupKeyForUser(userId), + Utils.fromBufferToB64(clientKeyHalf), ); } - async deleteBiometricKey(service: string, key: string): Promise { - await passwords.deletePassword(service, key); - await passwords.deletePassword(service, key + KEY_WITNESS_SUFFIX); + async deleteBiometricKey(userId: UserId): Promise { + await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId)); + await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId) + KEY_WITNESS_SUFFIX); } async authenticateBiometric(): Promise { @@ -240,13 +264,58 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { return result; } - async osBiometricsNeedsSetup() { + async needsSetup() { return false; } - async osBiometricsCanAutoSetup(): Promise { + async canAutoSetup(): Promise { return false; } - async osBiometricsSetup(): Promise {} + async runSetup(): Promise {} + + async getOrCreateBiometricEncryptionClientKeyHalf( + userId: UserId, + key: SymmetricCryptoKey, + ): Promise { + const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId); + if (!requireClientKeyHalf) { + return null; + } + + if (this.clientKeyHalves.has(userId)) { + return this.clientKeyHalves.get(userId); + } + + // Retrieve existing key half if it exists + let clientKeyHalf: Uint8Array | null = null; + const encryptedClientKeyHalf = + await this.biometricStateService.getEncryptedClientKeyHalf(userId); + if (encryptedClientKeyHalf != null) { + clientKeyHalf = await this.encryptService.decryptBytes(encryptedClientKeyHalf, key); + } + if (clientKeyHalf == null) { + // Set a key half if it doesn't exist + const keyBytes = await this.cryptoFunctionService.randomBytes(32); + const encKey = await this.encryptService.encryptBytes(keyBytes, key); + await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId); + } + + this.clientKeyHalves.set(userId, clientKeyHalf); + + return clientKeyHalf; + } + + async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { + const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId); + if (!requireClientKeyHalf) { + return BiometricsStatus.Available; + } + + if (this.clientKeyHalves.has(userId)) { + return BiometricsStatus.Available; + } else { + return BiometricsStatus.UnlockNeeded; + } + } } diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts index f5132200149..63e0527c034 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts @@ -1,32 +1,28 @@ +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BiometricsStatus } from "@bitwarden/key-management"; + export interface OsBiometricService { - osSupportsBiometric(): Promise; + supportsBiometrics(): Promise; /** * Check whether support for biometric unlock requires setup. This can be automatic or manual. * * @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place) */ - osBiometricsNeedsSetup: () => Promise; + needsSetup(): Promise; /** * Check whether biometrics can be automatically setup, or requires user interaction. * * @returns true if biometrics support can be automatically setup, false if it requires user interaction. */ - osBiometricsCanAutoSetup: () => Promise; + canAutoSetup(): Promise; /** * Starts automatic biometric setup, which places the required configuration files / changes the required settings. */ - osBiometricsSetup: () => Promise; + runSetup(): Promise; authenticateBiometric(): Promise; - getBiometricKey( - service: string, - key: string, - clientKeyHalfB64: string | undefined, - ): Promise; - setBiometricKey( - service: string, - key: string, - value: string, - clientKeyHalfB64: string | undefined, - ): Promise; - deleteBiometricKey(service: string, key: string): Promise; + getBiometricKey(userId: UserId): Promise; + setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise; + deleteBiometricKey(userId: UserId): Promise; + getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise; } diff --git a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts index 1404d65ae51..c7ed88d390f 100644 --- a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts @@ -34,8 +34,14 @@ export class RendererBiometricsService extends DesktopBiometricsService { return await ipc.keyManagement.biometric.getBiometricsStatusForUser(id); } - async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise { - return await ipc.keyManagement.biometric.setBiometricProtectedUnlockKeyForUser(userId, value); + async setBiometricProtectedUnlockKeyForUser( + userId: UserId, + value: SymmetricCryptoKey, + ): Promise { + return await ipc.keyManagement.biometric.setBiometricProtectedUnlockKeyForUser( + userId, + value.toBase64(), + ); } async deleteBiometricUnlockKeyForUser(userId: UserId): Promise { @@ -46,10 +52,6 @@ export class RendererBiometricsService extends DesktopBiometricsService { return await ipc.keyManagement.biometric.setupBiometrics(); } - async setClientKeyHalfForUser(userId: UserId, value: string | null): Promise { - return await ipc.keyManagement.biometric.setClientKeyHalf(userId, value); - } - async getShouldAutopromptNow(): Promise { return await ipc.keyManagement.biometric.getShouldAutoprompt(); } diff --git a/apps/desktop/src/key-management/electron-key.service.spec.ts b/apps/desktop/src/key-management/electron-key.service.spec.ts index 7a0464f5e27..730ad7e4652 100644 --- a/apps/desktop/src/key-management/electron-key.service.spec.ts +++ b/apps/desktop/src/key-management/electron-key.service.spec.ts @@ -9,14 +9,11 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; import { BiometricStateService, KdfConfigService } from "@bitwarden/key-management"; import { - makeEncString, - makeStaticByteArray, makeSymmetricCryptoKey, FakeAccountService, mockAccountServiceWith, @@ -80,7 +77,6 @@ describe("ElectronKeyService", () => { await keyService.setUserKey(userKey, mockUserId); - expect(biometricService.setClientKeyHalfForUser).not.toHaveBeenCalled(); expect(biometricService.setBiometricProtectedUnlockKeyForUser).not.toHaveBeenCalled(); expect(biometricStateService.setEncryptedClientKeyHalf).not.toHaveBeenCalled(); expect(biometricStateService.getBiometricUnlockEnabled).toHaveBeenCalledWith(mockUserId); @@ -96,14 +92,12 @@ describe("ElectronKeyService", () => { await keyService.setUserKey(userKey, mockUserId); - expect(biometricService.setClientKeyHalfForUser).toHaveBeenCalledWith(mockUserId, null); expect(biometricService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith( mockUserId, - userKey.keyB64, + userKey, ); expect(biometricStateService.setEncryptedClientKeyHalf).not.toHaveBeenCalled(); expect(biometricStateService.getBiometricUnlockEnabled).toHaveBeenCalledWith(mockUserId); - expect(biometricStateService.getRequirePasswordOnStart).toHaveBeenCalledWith(mockUserId); }); describe("require password on start enabled", () => { @@ -111,73 +105,11 @@ describe("ElectronKeyService", () => { biometricStateService.getRequirePasswordOnStart.mockResolvedValue(true); }); - it("sets new biometric client key half and biometric unlock key when no biometric client key half stored", async () => { - const clientKeyHalfBytes = makeStaticByteArray(32); - const clientKeyHalf = Utils.fromBufferToUtf8(clientKeyHalfBytes); - const encryptedClientKeyHalf = makeEncString(); - biometricStateService.getEncryptedClientKeyHalf.mockResolvedValue(null); - cryptoFunctionService.randomBytes.mockResolvedValue( - clientKeyHalfBytes.buffer as CsprngArray, - ); - encryptService.encryptString.mockResolvedValue(encryptedClientKeyHalf); - + it("sets biometric key", async () => { await keyService.setUserKey(userKey, mockUserId); - expect(biometricService.setClientKeyHalfForUser).toHaveBeenCalledWith( - mockUserId, - clientKeyHalf, - ); expect(biometricService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith( mockUserId, - userKey.keyB64, - ); - expect(biometricStateService.setEncryptedClientKeyHalf).toHaveBeenCalledWith( - encryptedClientKeyHalf, - mockUserId, - ); - expect(biometricStateService.getBiometricUnlockEnabled).toHaveBeenCalledWith( - mockUserId, - ); - expect(biometricStateService.getRequirePasswordOnStart).toHaveBeenCalledWith( - mockUserId, - ); - expect(biometricStateService.getEncryptedClientKeyHalf).toHaveBeenCalledWith( - mockUserId, - ); - expect(cryptoFunctionService.randomBytes).toHaveBeenCalledWith(32); - expect(encryptService.encryptString).toHaveBeenCalledWith(clientKeyHalf, userKey); - }); - - it("sets decrypted biometric client key half and biometric unlock key when existing biometric client key half stored", async () => { - const encryptedClientKeyHalf = makeEncString(); - const clientKeyHalf = Utils.fromBufferToUtf8(makeStaticByteArray(32)); - biometricStateService.getEncryptedClientKeyHalf.mockResolvedValue( - encryptedClientKeyHalf, - ); - encryptService.decryptString.mockResolvedValue(clientKeyHalf); - - await keyService.setUserKey(userKey, mockUserId); - - expect(biometricService.setClientKeyHalfForUser).toHaveBeenCalledWith( - mockUserId, - clientKeyHalf, - ); - expect(biometricService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith( - mockUserId, - userKey.keyB64, - ); - expect(biometricStateService.setEncryptedClientKeyHalf).not.toHaveBeenCalled(); - expect(biometricStateService.getBiometricUnlockEnabled).toHaveBeenCalledWith( - mockUserId, - ); - expect(biometricStateService.getRequirePasswordOnStart).toHaveBeenCalledWith( - mockUserId, - ); - expect(biometricStateService.getEncryptedClientKeyHalf).toHaveBeenCalledWith( - mockUserId, - ); - expect(encryptService.decryptString).toHaveBeenCalledWith( - encryptedClientKeyHalf, userKey, ); }); diff --git a/apps/desktop/src/key-management/electron-key.service.ts b/apps/desktop/src/key-management/electron-key.service.ts index d31e717e7a5..8a6fbfa085f 100644 --- a/apps/desktop/src/key-management/electron-key.service.ts +++ b/apps/desktop/src/key-management/electron-key.service.ts @@ -8,9 +8,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { StateProvider } from "@bitwarden/common/platform/state"; -import { CsprngString } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; import { @@ -77,10 +75,7 @@ export class ElectronKeyService extends DefaultKeyService { } private async storeBiometricsProtectedUserKey(userKey: UserKey, userId: UserId): Promise { - // May resolve to null, in which case no client key have is required - const clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(userKey, userId); - await this.biometricService.setClientKeyHalfForUser(userId, clientEncKeyHalf); - await this.biometricService.setBiometricProtectedUnlockKeyForUser(userId, userKey.keyB64); + await this.biometricService.setBiometricProtectedUnlockKeyForUser(userId, userKey); } protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId: UserId): Promise { @@ -91,34 +86,4 @@ export class ElectronKeyService extends DefaultKeyService { await this.biometricService.deleteBiometricUnlockKeyForUser(userId); await super.clearAllStoredUserKeys(userId); } - - private async getBiometricEncryptionClientKeyHalf( - userKey: UserKey, - userId: UserId, - ): Promise { - const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId); - if (!requireClientKeyHalf) { - return null; - } - - // Retrieve existing key half if it exists - let clientKeyHalf: CsprngString | null = null; - const encryptedClientKeyHalf = - await this.biometricStateService.getEncryptedClientKeyHalf(userId); - if (encryptedClientKeyHalf != null) { - clientKeyHalf = (await this.encryptService.decryptString( - encryptedClientKeyHalf, - userKey, - )) as CsprngString; - } - if (clientKeyHalf == null) { - // Set a key half if it doesn't exist - const keyBytes = await this.cryptoFunctionService.randomBytes(32); - clientKeyHalf = Utils.fromBufferToUtf8(keyBytes) as CsprngString; - const encKey = await this.encryptService.encryptString(clientKeyHalf, userKey); - await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId); - } - - return clientKeyHalf; - } } diff --git a/apps/desktop/src/key-management/preload.ts b/apps/desktop/src/key-management/preload.ts index 3e90c27ab03..7f8576b8472 100644 --- a/apps/desktop/src/key-management/preload.ts +++ b/apps/desktop/src/key-management/preload.ts @@ -25,12 +25,13 @@ const biometric = { action: BiometricAction.GetStatusForUser, userId: userId, } satisfies BiometricMessage), - setBiometricProtectedUnlockKeyForUser: (userId: string, value: string): Promise => - ipcRenderer.invoke("biometric", { + setBiometricProtectedUnlockKeyForUser: (userId: string, keyB64: string): Promise => { + return ipcRenderer.invoke("biometric", { action: BiometricAction.SetKeyForUser, userId: userId, - key: value, - } satisfies BiometricMessage), + key: keyB64, + } satisfies BiometricMessage); + }, deleteBiometricUnlockKeyForUser: (userId: string): Promise => ipcRenderer.invoke("biometric", { action: BiometricAction.RemoveKeyForUser, @@ -40,12 +41,6 @@ const biometric = { ipcRenderer.invoke("biometric", { action: BiometricAction.Setup, } satisfies BiometricMessage), - setClientKeyHalf: (userId: string, value: string | null): Promise => - ipcRenderer.invoke("biometric", { - action: BiometricAction.SetClientKeyHalf, - userId: userId, - key: value, - } satisfies BiometricMessage), getShouldAutoprompt: (): Promise => ipcRenderer.invoke("biometric", { action: BiometricAction.GetShouldAutoprompt, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 0cc466196fb..1431ab72020 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -23,6 +23,9 @@ "typeIdentity": { "message": "Identity" }, + "typeNote": { + "message": "Note" + }, "typeSecureNote": { "message": "Secure note" }, @@ -3812,5 +3815,139 @@ "message": "Learn more about SSH agent", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescriptionSingular": { + "message": "Only organization members with access to these collections will be able to see the item." + }, + "bulkCollectionAssignmentDialogDescriptionPlural": { + "message": "Only organization members with access to these collections will be able to see the items." + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "assign": { + "message": "Assign" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Only organization members with access to these collections will be able to see the items." + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2" + } + } + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "personalItemsTransferWarning": { + "message": "$PERSONAL_ITEMS_COUNT$ will be permanently transferred to the selected organization. You will no longer own these items.", + "placeholders": { + "personal_items_count": { + "content": "$1", + "example": "2 items" + } + } + }, + "personalItemsWithOrgTransferWarning": { + "message": "$PERSONAL_ITEMS_COUNT$ will be permanently transferred to $ORG$. You will no longer own these items.", + "placeholders": { + "personal_items_count": { + "content": "$1", + "example": "2 items" + }, + "org": { + "content": "$2", + "example": "Organization name" + } + } + }, + "personalItemTransferWarningSingular": { + "message": "1 item will be permanently transferred to the selected organization. You will no longer own this item." + }, + "personalItemWithOrgTransferWarningSingular": { + "message": "1 item will be permanently transferred to $ORG$. You will no longer own this item.", + "placeholders": { + "org": { + "content": "$1", + "example": "Organization name" + } + } + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "nothingSelected": { + "message": "You have not selected anything." + }, + "itemsMovedToOrg": { + "message": "Items moved to $ORGNAME$", + "placeholders": { + "orgname": { + "content": "$1", + "example": "Company Name" + } + } + }, + "itemMovedToOrg": { + "message": "Item moved to $ORGNAME$", + "placeholders": { + "orgname": { + "content": "$1", + "example": "Company Name" + } + } + }, + "movedItemsToOrg": { + "message": "Selected items moved to $ORGNAME$", + "placeholders": { + "orgname": { + "content": "$1", + "example": "Company Name" + } + }, + "personalItemsTransferWarningPlural": { + "message": "$PERSONAL_ITEMS_COUNT$ items will be permanently transferred to the selected organization. You will no longer own these items.", + "placeholders": { + "personal_items_count": { + "content": "$1", + "example": "2" + } + } + }, + "personalItemWithOrgTransferWarningSingular": { + "message": "1 item will be permanently transferred to $ORG$. You will no longer own this item.", + "placeholders": { + "org": { + "content": "$1", + "example": "Organization name" + } + } + }, + "personalItemsWithOrgTransferWarningPlural": { + "message": "$PERSONAL_ITEMS_COUNT$ items will be permanently transferred to $ORG$. You will no longer own these items.", + "placeholders": { + "personal_items_count": { + "content": "$1", + "example": "2" + }, + "org": { + "content": "$2", + "example": "Organization name" + } + } + } } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 5e0ea7f9fac..7d97805e9be 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -10,6 +10,7 @@ import { Subject, firstValueFrom } from "rxjs"; import { SsoUrlService } from "@bitwarden/auth/common"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { ClientType } from "@bitwarden/common/enums"; +import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; import { RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service"; import { Message, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- For dependency creation @@ -187,14 +188,19 @@ export class Main { this.desktopSettingsService = new DesktopSettingsService(stateProvider); const biometricStateService = new DefaultBiometricStateService(stateProvider); - + const encryptService = new EncryptServiceImplementation( + this.mainCryptoFunctionService, + this.logService, + true, + ); this.biometricsService = new MainBiometricsService( this.i18nService, this.windowMain, this.logService, - this.messagingService, process.platform, biometricStateService, + encryptService, + this.mainCryptoFunctionService, ); this.windowMain = new WindowMain( diff --git a/apps/desktop/src/types/biometric-message.ts b/apps/desktop/src/types/biometric-message.ts index 7616b265005..9711b49496d 100644 --- a/apps/desktop/src/types/biometric-message.ts +++ b/apps/desktop/src/types/biometric-message.ts @@ -9,8 +9,6 @@ export enum BiometricAction { SetKeyForUser = "setKeyForUser", RemoveKeyForUser = "removeKeyForUser", - SetClientKeyHalf = "setClientKeyHalf", - Setup = "setup", GetShouldAutoprompt = "getShouldAutoprompt", @@ -18,21 +16,13 @@ export enum BiometricAction { } export type BiometricMessage = - | { - action: BiometricAction.SetClientKeyHalf; - userId: string; - key: string | null; - } | { action: BiometricAction.SetKeyForUser; userId: string; key: string; } | { - action: Exclude< - BiometricAction, - BiometricAction.SetClientKeyHalf | BiometricAction.SetKeyForUser - >; + action: Exclude; userId?: string; data?: any; }; diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.html b/apps/desktop/src/vault/app/vault/add-edit.component.html index 9c316813d1d..2cd384885ce 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.html +++ b/apps/desktop/src/vault/app/vault/add-edit.component.html @@ -12,7 +12,9 @@
diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.ts b/apps/desktop/src/vault/app/vault/add-edit.component.ts index eb04003a418..e9b18270f2d 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.ts +++ b/apps/desktop/src/vault/app/vault/add-edit.component.ts @@ -3,6 +3,7 @@ import { DatePipe } from "@angular/common"; import { Component, NgZone, OnChanges, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { NgForm } from "@angular/forms"; +import { map, shareReplay } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component"; @@ -22,6 +23,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault"; @@ -35,6 +38,18 @@ const BroadcasterSubscriptionId = "AddEditComponent"; export class AddEditComponent extends BaseAddEditComponent implements OnInit, OnChanges, OnDestroy { @ViewChild("form") private form: NgForm; + menuItems$ = this.restrictedItemTypesService.restricted$.pipe( + map((restrictedItemTypes) => + // Filter out restricted item types from the default CIPHER_MENU_ITEMS array + CIPHER_MENU_ITEMS.filter( + (typeOption) => + !restrictedItemTypes.some( + (restrictedType) => restrictedType.cipherType === typeOption.type, + ), + ), + ), + shareReplay({ bufferSize: 1, refCount: true }), + ); constructor( cipherService: CipherService, @@ -59,6 +74,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On cipherAuthorizationService: CipherAuthorizationService, sdkService: SdkService, sshImportPromptService: SshImportPromptService, + protected restrictedItemTypesService: RestrictedItemTypesService, ) { super( cipherService, diff --git a/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.html b/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.html new file mode 100644 index 00000000000..4f5b6234ad9 --- /dev/null +++ b/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.html @@ -0,0 +1,33 @@ + + + {{ "assignToCollections" | i18n }} + + {{ editableItemCount | pluralize: ("item" | i18n) : ("items" | i18n) }} + + + +
+ +
+ + + + + +
diff --git a/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts b/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts new file mode 100644 index 00000000000..d81f1662c6c --- /dev/null +++ b/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts @@ -0,0 +1,36 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { PluralizePipe } from "@bitwarden/angular/pipes/pluralize.pipe"; +import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { + AssignCollectionsComponent, + CollectionAssignmentParams, + CollectionAssignmentResult, +} from "@bitwarden/vault"; + +@Component({ + standalone: true, + templateUrl: "./assign-collections-desktop.component.html", + imports: [AssignCollectionsComponent, PluralizePipe, DialogModule, ButtonModule, JslibModule], +}) +export class AssignCollectionsDesktopComponent { + protected editableItemCount: number; + + constructor( + @Inject(DIALOG_DATA) public params: CollectionAssignmentParams, + private dialogRef: DialogRef, + ) {} + + protected async onCollectionAssign(result: CollectionAssignmentResult) { + this.dialogRef.close(result); + } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open( + AssignCollectionsDesktopComponent, + config, + ); + } +} diff --git a/apps/desktop/src/vault/app/vault/assign-collections/index.ts b/apps/desktop/src/vault/app/vault/assign-collections/index.ts new file mode 100644 index 00000000000..1afe7128757 --- /dev/null +++ b/apps/desktop/src/vault/app/vault/assign-collections/index.ts @@ -0,0 +1 @@ +export * from "./assign-collections-desktop.component"; diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.html b/apps/desktop/src/vault/app/vault/item-footer.component.html index 5a067da372e..c41bf254c80 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.html +++ b/apps/desktop/src/vault/app/vault/item-footer.component.html @@ -36,7 +36,7 @@ class="primary" (click)="restore()" appA11yTitle="{{ 'restore' | i18n }}" - *ngIf="cipher.isDeleted" + *ngIf="cipher.isDeleted && cipher.permissions.restore" > @@ -50,7 +50,7 @@ -
+
    -
  • - - - -
  • -
  • - - - -
  • -
  • - - - -
  • -
  • - - - -
  • -
  • - - - -
  • + @for (typeFilter of typeFilters$ | async; track typeFilter) { +
  • + + + +
  • + }
diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.ts index 5920233b206..27e7d5c5ecb 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.ts @@ -1,6 +1,9 @@ import { Component } from "@angular/core"; +import { map, shareReplay } from "rxjs"; import { TypeFilterComponent as BaseTypeFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/type-filter.component"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; @Component({ selector: "app-type-filter", @@ -8,7 +11,22 @@ import { TypeFilterComponent as BaseTypeFilterComponent } from "@bitwarden/angul standalone: false, }) export class TypeFilterComponent extends BaseTypeFilterComponent { - constructor() { + protected typeFilters$ = this.restrictedItemTypesService.restricted$.pipe( + map((restrictedItemTypes) => + // Filter out restricted item types from the typeFilters array + CIPHER_MENU_ITEMS.filter( + (typeFilter) => + !restrictedItemTypes.some( + (restrictedType) => + restrictedType.allowViewOrgIds.length === 0 && + restrictedType.cipherType === typeFilter.type, + ), + ), + ), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + constructor(private restrictedItemTypesService: RestrictedItemTypesService) { super(); } } diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.html b/apps/desktop/src/vault/app/vault/vault-items-v2.component.html index 63e648e3cf3..fcf38ee39bc 100644 --- a/apps/desktop/src/vault/app/vault/vault-items-v2.component.html +++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.html @@ -72,25 +72,11 @@ - - - - - + @for (itemTypes of itemTypes$ | async; track itemTypes.type) { + + } diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts index 5a832ed79b0..1256c9e52e8 100644 --- a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts @@ -10,6 +10,7 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { MenuModule } from "@bitwarden/components"; import { SearchBarService } from "../../../app/layout/search/search-bar.service"; @@ -25,8 +26,9 @@ export class VaultItemsV2Component extends BaseVaultItemsComponent { private readonly searchBarService: SearchBarService, cipherService: CipherService, accountService: AccountService, + restrictedItemTypesService: RestrictedItemTypesService, ) { - super(searchService, cipherService, accountService); + super(searchService, cipherService, accountService, restrictedItemTypesService); this.searchBarService.searchText$ .pipe(distinctUntilChanged(), takeUntilDestroyed()) diff --git a/apps/desktop/src/vault/app/vault/vault-items.component.ts b/apps/desktop/src/vault/app/vault/vault-items.component.ts index 2d1ba784753..8bf4955343d 100644 --- a/apps/desktop/src/vault/app/vault/vault-items.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-items.component.ts @@ -8,6 +8,7 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { SearchBarService } from "../../../app/layout/search/search-bar.service"; @@ -22,8 +23,9 @@ export class VaultItemsComponent extends BaseVaultItemsComponent { searchBarService: SearchBarService, cipherService: CipherService, accountService: AccountService, + protected restrictedItemTypesService: RestrictedItemTypesService, ) { - super(searchService, cipherService, accountService); + super(searchService, cipherService, accountService, restrictedItemTypesService); // eslint-disable-next-line rxjs-angular/prefer-takeuntil searchBarService.searchText$.pipe(distinctUntilChanged()).subscribe((searchText) => { diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.html b/apps/desktop/src/vault/app/vault/vault-v2.component.html index 4dd23466126..f1cb28f3ea5 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.html +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.html @@ -20,6 +20,7 @@ (onDelete)="deleteCipher()" (onCancel)="cancelCipher($event)" [isSubmitting]="isSubmitting" + [masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId" >
diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 50e6bfb51c7..849899bfe66 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -9,16 +9,16 @@ import { ViewContainerRef, } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom, Subject, takeUntil, switchMap, lastValueFrom } from "rxjs"; +import { firstValueFrom, Subject, takeUntil, switchMap, lastValueFrom, Observable } from "rxjs"; import { filter, map, take } from "rxjs/operators"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; -import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; @@ -43,6 +43,8 @@ import { DialogService, ItemModule, ToastService, + CopyClickListener, + COPY_CLICK_LISTENER, } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { @@ -57,10 +59,12 @@ import { CipherFormMode, CipherFormModule, CipherViewComponent, + CollectionAssignmentResult, DecryptionFailureDialogComponent, DefaultChangeLoginPasswordService, DefaultCipherFormConfigService, PasswordRepromptService, + CipherFormComponent, } from "@bitwarden/vault"; import { NavComponent } from "../../../app/layout/nav.component"; @@ -69,6 +73,7 @@ import { DesktopCredentialGenerationService } from "../../../services/desktop-ci import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; import { invokeMenu, RendererMenuItem } from "../../../utils"; +import { AssignCollectionsDesktopComponent } from "./assign-collections"; import { ItemFooterComponent } from "./item-footer.component"; import { VaultFilterComponent } from "./vault-filter/vault-filter.component"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; @@ -110,15 +115,21 @@ const BroadcasterSubscriptionId = "VaultComponent"; useClass: DesktopPremiumUpgradePromptService, }, { provide: CipherFormGenerationService, useClass: DesktopCredentialGenerationService }, + { + provide: COPY_CLICK_LISTENER, + useExisting: VaultV2Component, + }, ], }) -export class VaultV2Component implements OnInit, OnDestroy { +export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { @ViewChild(VaultItemsV2Component, { static: true }) vaultItemsComponent: VaultItemsV2Component | null = null; @ViewChild(VaultFilterComponent, { static: true }) vaultFilterComponent: VaultFilterComponent | null = null; @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) folderAddEditModalRef: ViewContainerRef | null = null; + @ViewChild(CipherFormComponent) + cipherFormComponent: CipherFormComponent | null = null; action: CipherFormMode | "view" | null = null; cipherId: string | null = null; @@ -142,6 +153,11 @@ export class VaultV2Component implements OnInit, OnDestroy { config: CipherFormConfig | null = null; isSubmitting = false; + private organizations$: Observable = this.accountService.activeAccount$.pipe( + map((a) => a?.id), + switchMap((id) => this.organizationService.organizations$(id)), + ); + protected canAccessAttachments$ = this.accountService.activeAccount$.pipe( filter((account): account is Account => !!account), switchMap((account) => @@ -149,14 +165,14 @@ export class VaultV2Component implements OnInit, OnDestroy { ), ); - private modal: ModalRef | null = null; private componentIsDestroyed$ = new Subject(); + private allOrganizations: Organization[] = []; + private allCollections: CollectionView[] = []; constructor( private route: ActivatedRoute, private router: Router, private i18nService: I18nService, - private modalService: ModalService, private broadcasterService: BroadcasterService, private changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, @@ -176,6 +192,7 @@ export class VaultV2Component implements OnInit, OnDestroy { private formConfigService: CipherFormConfigService, private premiumUpgradePromptService: PremiumUpgradePromptService, private collectionService: CollectionService, + private organizationService: OrganizationService, private folderService: FolderService, ) {} @@ -312,6 +329,16 @@ export class VaultV2Component implements OnInit, OnDestroy { }); }); } + + this.organizations$.pipe(takeUntil(this.componentIsDestroyed$)).subscribe((orgs) => { + this.allOrganizations = orgs; + }); + + this.collectionService.decryptedCollections$ + .pipe(takeUntil(this.componentIsDestroyed$)) + .subscribe((collections) => { + this.allCollections = collections; + }); } ngOnDestroy() { @@ -353,6 +380,13 @@ export class VaultV2Component implements OnInit, OnDestroy { } } + /** + * Handler for Vault level CopyClickDirectives to send the minimizeOnCopy message + */ + onCopy() { + this.messagingService.send("minimizeOnCopy"); + } + async viewCipher(cipher: CipherView) { if (await this.shouldReprompt(cipher, "view")) { return; @@ -364,7 +398,14 @@ export class VaultV2Component implements OnInit, OnDestroy { cipher.collectionIds.includes(c.id), ) ?? null; this.action = "view"; + await this.go().catch(() => {}); + await this.eventCollectionService.collect( + EventType.Cipher_ClientViewed, + cipher.id, + false, + cipher.organizationId, + ); } async openAttachmentsDialog() { @@ -381,6 +422,26 @@ export class VaultV2Component implements OnInit, OnDestroy { result?.action === AttachmentDialogResult.Uploaded ) { await this.vaultItemsComponent?.refresh().catch(() => {}); + + if (this.cipherFormComponent == null) { + return; + } + + const updatedCipher = await this.cipherService.get( + this.cipherId as CipherId, + this.activeUserId as UserId, + ); + const updatedCipherView = await this.cipherService.decrypt( + updatedCipher, + this.activeUserId as UserId, + ); + + this.cipherFormComponent.patchCipher((currentCipher) => { + currentCipher.attachments = updatedCipherView.attachments; + currentCipher.revisionDate = updatedCipherView.revisionDate; + + return currentCipher; + }); } } @@ -420,6 +481,16 @@ export class VaultV2Component implements OnInit, OnDestroy { }, }); } + + if (cipher.canAssignToCollections) { + menu.push({ + label: this.i18nService.t("assignToCollections"), + click: () => + this.functionWithChangeDetection(async () => { + await this.shareCipher(cipher); + }), + }); + } } switch (cipher.type) { @@ -531,6 +602,36 @@ export class VaultV2Component implements OnInit, OnDestroy { await this.go().catch(() => {}); } + async shareCipher(cipher: CipherView) { + if (!cipher) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("nothingSelected"), + }); + return; + } + + if (!(await this.passwordReprompt(cipher))) { + return; + } + + const availableCollections = this.getAvailableCollections(cipher); + + const dialog = AssignCollectionsDesktopComponent.open(this.dialogService, { + data: { + ciphers: [cipher], + organizationId: cipher.organizationId as OrganizationId, + availableCollections, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === CollectionAssignmentResult.Saved) { + await this.savedCipher(cipher); + } + } + async addCipher(type: CipherType) { if (this.action === "add") { return; @@ -603,6 +704,16 @@ export class VaultV2Component implements OnInit, OnDestroy { await this.go().catch(() => {}); } + private getAvailableCollections(cipher: CipherView): CollectionView[] { + const orgId = cipher.organizationId; + if (!orgId || orgId === "MyVault") { + return []; + } + + const organization = this.allOrganizations.find((o) => o.id === orgId); + return this.allCollections.filter((c) => c.organizationId === organization?.id && !c.readOnly); + } + private calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string { if (vaultFilter.status === "favorites") { return "searchFavorites"; @@ -633,10 +744,17 @@ export class VaultV2Component implements OnInit, OnDestroy { } async editFolder(folderId: string) { + if (!this.activeUserId) { + return; + } const folderView = await firstValueFrom( this.folderService.getDecrypted$(folderId, this.activeUserId), ); + if (!folderView) { + return; + } + const dialogRef = AddEditFolderDialogComponent.open(this.dialogService, { editFolderConfig: { folder: { @@ -651,7 +769,7 @@ export class VaultV2Component implements OnInit, OnDestroy { result === AddEditFolderDialogResult.Deleted || result === AddEditFolderDialogResult.Created ) { - await this.vaultFilterComponent.reloadCollectionsAndFolders(this.activeFilter); + await this.vaultFilterComponent?.reloadCollectionsAndFolders(this.activeFilter); } } @@ -696,10 +814,6 @@ export class VaultV2Component implements OnInit, OnDestroy { .catch(() => {}); } - private addCipherWithChangeDetection(type: CipherType) { - this.functionWithChangeDetection(() => this.addCipher(type).catch(() => {})); - } - private copyValue(cipher: CipherView, value: string, labelI18nKey: string, aType: string) { this.functionWithChangeDetection(() => { (async () => { diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index ff6ec9af0af..49bf43d60bf 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -11,8 +11,8 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { RestrictedItemTypesService } from "@bitwarden/vault"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component"; import { VaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; diff --git a/apps/web/src/app/admin-console/organizations/manage/groups.component.html b/apps/web/src/app/admin-console/organizations/manage/groups.component.html index caae23f500d..4518513ba7d 100644 --- a/apps/web/src/app/admin-console/organizations/manage/groups.component.html +++ b/apps/web/src/app/admin-console/organizations/manage/groups.component.html @@ -22,7 +22,7 @@

{{ "noGroupsInList" | i18n }}

- + diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 610821cfd1b..962191021e8 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -75,7 +75,7 @@ - + diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index 81697f8c845..98431758d2f 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -3,6 +3,7 @@ import { NgModule } from "@angular/core"; import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; +import { ScrollLayoutDirective } from "@bitwarden/components"; import { LooseComponentsModule } from "../../../shared"; import { SharedOrganizationModule } from "../shared"; @@ -27,6 +28,7 @@ import { MembersComponent } from "./members.component"; PasswordCalloutComponent, ScrollingModule, PasswordStrengthV2Component, + ScrollLayoutDirective, ], declarations: [ BulkConfirmDialogComponent, diff --git a/apps/web/src/app/admin-console/organizations/organization-routing.module.ts b/apps/web/src/app/admin-console/organizations/organization-routing.module.ts index 4d8971f74fd..ab32a0b1eef 100644 --- a/apps/web/src/app/admin-console/organizations/organization-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/organization-routing.module.ts @@ -17,7 +17,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { deepLinkGuard } from "../../auth/guards/deep-link/deep-link.guard"; import { VaultModule } from "./collections/vault.module"; -import { isEnterpriseOrgGuard } from "./guards/is-enterprise-org.guard"; import { organizationPermissionsGuard } from "./guards/org-permissions.guard"; import { organizationRedirectGuard } from "./guards/org-redirect.guard"; import { AdminConsoleIntegrationsComponent } from "./integrations/integrations.component"; @@ -42,10 +41,7 @@ const routes: Routes = [ }, { path: "integrations", - canActivate: [ - isEnterpriseOrgGuard(false), - organizationPermissionsGuard(canAccessIntegrations), - ], + canActivate: [organizationPermissionsGuard(canAccessIntegrations)], component: AdminConsoleIntegrationsComponent, data: { titleId: "integrations", diff --git a/apps/web/src/app/admin-console/organizations/organization.module.ts b/apps/web/src/app/admin-console/organizations/organization.module.ts index 459948d0f13..687361760c9 100644 --- a/apps/web/src/app/admin-console/organizations/organization.module.ts +++ b/apps/web/src/app/admin-console/organizations/organization.module.ts @@ -1,6 +1,8 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; import { NgModule } from "@angular/core"; +import { ScrollLayoutDirective } from "@bitwarden/components"; + import { LooseComponentsModule } from "../../shared"; import { CoreOrganizationModule } from "./core"; @@ -18,6 +20,7 @@ import { AccessSelectorModule } from "./shared/components/access-selector"; OrganizationsRoutingModule, LooseComponentsModule, ScrollingModule, + ScrollLayoutDirective, ], declarations: [GroupsComponent, GroupAddEditComponent], }) diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html index 4226862fde7..088b5051fb1 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html @@ -1,22 +1,21 @@
- + {{ "permission" | i18n }} - + + + - + {{ selectorLabelText }} - +
@@ -79,28 +78,22 @@ - -
- - -
+ + + +
diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.spec.ts b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.spec.ts index 86c348f0326..a5a632678c9 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.spec.ts @@ -14,6 +14,7 @@ import { ButtonModule, FormFieldModule, IconButtonModule, + SelectModule, TableModule, TabsModule, } from "@bitwarden/components"; @@ -71,6 +72,7 @@ describe("AccessSelectorComponent", () => { PreloadedEnglishI18nModule, JslibModule, IconButtonModule, + SelectModule, ], declarations: [TestableAccessSelectorComponent, UserTypePipe], providers: [], diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.stories.ts b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.stories.ts index 095be1df966..e98160d78d0 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.stories.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.stories.ts @@ -10,6 +10,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, + SelectModule, TableModule, TabsModule, } from "@bitwarden/components"; @@ -47,6 +48,7 @@ export default { TableModule, JslibModule, IconButtonModule, + SelectModule, ], providers: [], }), 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 e9865f14d54..8763b75ffca 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 @@ -591,5 +591,5 @@ export function openCollectionDialog( dialogService: DialogService, config: DialogConfig>, ) { - return dialogService.open(CollectionDialogComponent, config); + return dialogService.open(CollectionDialogComponent, config); } diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts index 4df6defe8ad..b0c89cd30ab 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts +++ b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts @@ -3,11 +3,10 @@ import { Component, inject } from "@angular/core"; import { Params } from "@angular/router"; -import { BitwardenLogo } from "@bitwarden/auth/angular"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { OrganizationSponsorshipResponse } from "@bitwarden/common/admin-console/models/response/organization-sponsorship.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { ToastService } from "@bitwarden/components"; +import { Icons, ToastService } from "@bitwarden/components"; import { BaseAcceptComponent } from "../../../common/base.accept.component"; @@ -22,7 +21,7 @@ import { BaseAcceptComponent } from "../../../common/base.accept.component"; standalone: false, }) export class AcceptFamilySponsorshipComponent extends BaseAcceptComponent { - protected logo = BitwardenLogo; + protected logo = Icons.BitwardenLogo; failedShortMessage = "inviteAcceptFailedShort"; failedMessage = "inviteAcceptFailed"; diff --git a/apps/web/src/app/auth/settings/security/device-management.component.spec.ts b/apps/web/src/app/auth/settings/security/device-management.component.spec.ts index 84c1dfcb63b..d86123f52be 100644 --- a/apps/web/src/app/auth/settings/security/device-management.component.spec.ts +++ b/apps/web/src/app/auth/settings/security/device-management.component.spec.ts @@ -9,7 +9,13 @@ import { DeviceType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; -import { DialogService, ToastService, TableModule, PopoverModule } from "@bitwarden/components"; +import { + DialogService, + ToastService, + TableModule, + PopoverModule, + LayoutComponent, +} from "@bitwarden/components"; import { SharedModule } from "../../../shared"; import { VaultBannersService } from "../../../vault/individual-vault/vault-banners/services/vault-banners.service"; @@ -115,6 +121,12 @@ describe("DeviceManagementComponent", () => { showError: jest.fn(), }, }, + { + provide: LayoutComponent, + useValue: { + mainContent: jest.fn(), + }, + }, ], }).compileComponents(); diff --git a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts index f3c01e41dbb..38ae39cabfe 100644 --- a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts +++ b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts @@ -1,4 +1,3 @@ -import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; import { AbstractControl, @@ -19,7 +18,10 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrgKey } from "@bitwarden/common/types/key"; import { + DialogRef, ButtonModule, + DialogConfig, + DIALOG_DATA, DialogModule, DialogService, FormFieldModule, diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts index 0e3b682104e..edd918ce059 100644 --- a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts @@ -1,4 +1,3 @@ -import { DialogRef } from "@angular/cdk/dialog"; import { formatDate } from "@angular/common"; import { Component, OnInit, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @@ -16,7 +15,7 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { StateProvider } from "@bitwarden/common/platform/state"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { AddSponsorshipDialogComponent } from "./add-sponsorship-dialog.component"; diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 6a7cc51d3ba..783fe6ada0a 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -11,8 +11,6 @@ import { activeAuthGuard, } from "@bitwarden/angular/auth/guards"; import { - AnonLayoutWrapperComponent, - AnonLayoutWrapperData, PasswordHintComponent, RegistrationFinishComponent, RegistrationStartComponent, @@ -22,7 +20,6 @@ import { RegistrationLinkExpiredComponent, LoginComponent, LoginSecondaryContentComponent, - LockIcon, TwoFactorTimeoutIcon, UserLockIcon, SsoKeyIcon, @@ -39,6 +36,7 @@ import { NewDeviceVerificationComponent, DeviceVerificationIcon, } from "@bitwarden/auth/angular"; +import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, Icons } from "@bitwarden/components"; import { LockComponent } from "@bitwarden/key-management-ui"; import { VaultIcons } from "@bitwarden/vault"; @@ -399,7 +397,7 @@ const routes: Routes = [ pageTitle: { key: "yourVaultIsLockedV2", }, - pageIcon: LockIcon, + pageIcon: Icons.LockIcon, showReadonlyHostname: true, } satisfies AnonLayoutWrapperData, }, diff --git a/apps/web/src/app/tools/send/send-access/access.component.ts b/apps/web/src/app/tools/send/send-access/access.component.ts index 2676cb9bef4..bc2851f0df7 100644 --- a/apps/web/src/app/tools/send/send-access/access.component.ts +++ b/apps/web/src/app/tools/send/send-access/access.component.ts @@ -4,7 +4,6 @@ import { Component, OnInit } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -17,7 +16,7 @@ import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; -import { NoItemsModule, ToastService } from "@bitwarden/components"; +import { AnonLayoutWrapperDataService, NoItemsModule, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { ExpiredSendIcon } from "@bitwarden/send-ui"; diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index 4b266ac5525..992c9c26bf3 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -1,4 +1,4 @@ - + diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts index e54a9c1141f..ab4f8bddb16 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts @@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { TableModule } from "@bitwarden/components"; +import { ScrollLayoutDirective, TableModule } from "@bitwarden/components"; import { CollectionNameBadgeComponent } from "../../../admin-console/organizations/collections"; import { GroupBadgeModule } from "../../../admin-console/organizations/collections/group-badge/group-badge.module"; @@ -26,6 +26,7 @@ import { VaultItemsComponent } from "./vault-items.component"; CollectionNameBadgeComponent, GroupBadgeModule, PipesModule, + ScrollLayoutDirective, ], declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent], exports: [VaultItemsComponent], diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index e2c6f204d72..e65d423a57b 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -2,7 +2,13 @@ // @ts-strict-ignore import { importProvidersFrom } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { + applicationConfig, + componentWrapperDecorator, + Meta, + moduleMetadata, + StoryObj, +} from "@storybook/angular"; import { BehaviorSubject, of } from "rxjs"; import { @@ -29,7 +35,8 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; -import { RestrictedItemTypesService } from "@bitwarden/vault"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { LayoutComponent } from "@bitwarden/components"; import { GroupView } from "../../../admin-console/organizations/core"; import { PreloadedEnglishI18nModule } from "../../../core/tests"; @@ -49,8 +56,9 @@ export default { title: "Web/Vault/Items", component: VaultItemsComponent, decorators: [ + componentWrapperDecorator((story) => `${story}`), moduleMetadata({ - imports: [VaultItemsModule, RouterModule], + imports: [VaultItemsModule, RouterModule, LayoutComponent], providers: [ { provide: EnvironmentService, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 8987fff04cf..72766817eeb 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -22,8 +22,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { RestrictedItemTypesService } from "@bitwarden/vault"; import { TrialFlowService } from "../../../../billing/services/trial-flow.service"; import { VaultFilterService } from "../services/abstractions/vault-filter.service"; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts index 660aeb293a4..2ec2b2c40a9 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts @@ -3,7 +3,7 @@ import { Unassigned } from "@bitwarden/admin-console/common"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { RestrictedCipherType } from "@bitwarden/vault"; +import { RestrictedCipherType } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { createFilterFunction } from "./filter-function"; import { All } from "./routed-vault-filter.model"; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts index 61305fa5e49..93071aecae3 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts @@ -1,7 +1,10 @@ import { Unassigned } from "@bitwarden/admin-console/common"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { RestrictedCipherType } from "@bitwarden/vault"; +import { + isCipherViewRestricted, + RestrictedCipherType, +} from "@bitwarden/common/vault/services/restricted-item-types.service"; import { All, RoutedVaultFilterModel } from "./routed-vault-filter.model"; @@ -83,24 +86,9 @@ export function createFilterFunction( ) { return false; } - // Restricted types - if (restrictedTypes && restrictedTypes.length > 0) { - // Filter the cipher if that type is restricted unless - // - The cipher belongs to an organization and that organization allows viewing the cipher type - // OR - // - The cipher belongs to the user's personal vault and at least one other organization does not restrict that type - if ( - restrictedTypes.some( - (restrictedType) => - restrictedType.cipherType === cipher.type && - (cipher.organizationId - ? !restrictedType.allowViewOrgIds.includes(cipher.organizationId) - : restrictedType.allowViewOrgIds.length === 0), - ) - ) { - return false; - } + if (restrictedTypes && isCipherViewRestricted(cipher, restrictedTypes)) { + return false; } return true; }; diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts index 48bc3a4268b..49e159143dd 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts @@ -18,13 +18,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { BreadcrumbsModule, DialogService, MenuModule, SimpleDialogOptions, } from "@bitwarden/components"; -import { RestrictedItemTypesService } from "@bitwarden/vault"; import { CollectionDialogTabType } from "../../../admin-console/organizations/shared/components/collection-dialog"; import { HeaderModule } from "../../../layouts/header/header.module"; 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 2c9079c7279..3d59a186705 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -66,6 +66,7 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { DialogRef, DialogService, Icons, ToastService } from "@bitwarden/components"; import { @@ -79,7 +80,6 @@ import { DecryptionFailureDialogComponent, DefaultCipherFormConfigService, PasswordRepromptService, - RestrictedItemTypesService, } from "@bitwarden/vault"; import { diff --git a/apps/web/src/app/vault/services/browser-extension-prompt.service.spec.ts b/apps/web/src/app/vault/services/browser-extension-prompt.service.spec.ts index f6f4ec4fdb4..5b4c6665aa0 100644 --- a/apps/web/src/app/vault/services/browser-extension-prompt.service.spec.ts +++ b/apps/web/src/app/vault/services/browser-extension-prompt.service.spec.ts @@ -1,9 +1,9 @@ import { TestBed } from "@angular/core/testing"; -import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; +import { AnonLayoutWrapperDataService } from "@bitwarden/components"; import { BrowserExtensionPromptService, diff --git a/apps/web/src/app/vault/services/browser-extension-prompt.service.ts b/apps/web/src/app/vault/services/browser-extension-prompt.service.ts index 0f401c04abe..a164a106917 100644 --- a/apps/web/src/app/vault/services/browser-extension-prompt.service.ts +++ b/apps/web/src/app/vault/services/browser-extension-prompt.service.ts @@ -2,11 +2,11 @@ import { DestroyRef, Injectable } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { BehaviorSubject, fromEvent } from "rxjs"; -import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { AnonLayoutWrapperDataService } from "@bitwarden/components"; export const BrowserPromptState = { Loading: "loading", diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 33468e0b306..6785c20d8f4 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7615,9 +7615,9 @@ "message": "Service account updated", "description": "Notifies that a service account has been updated" }, - "newSaSelectAccess": { - "message": "Type or select projects or secrets", - "description": "Instructions for selecting projects or secrets for a new service account" + "typeOrSelectProjects": { + "message": "Type or select projects", + "description": "Instructions for selecting projects for a service account" }, "newSaTypeToFilter": { "message": "Type to filter", diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts index 7a90403b0b9..7696742277a 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts @@ -3,12 +3,12 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; -import { BitwardenLogo } from "@bitwarden/auth/angular"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ProviderUserAcceptRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-accept.request"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Icons } from "@bitwarden/components"; import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.component"; @Component({ @@ -17,7 +17,7 @@ import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept standalone: false, }) export class AcceptProviderComponent extends BaseAcceptComponent { - protected logo = BitwardenLogo; + protected logo = Icons.BitwardenLogo; providerName: string; providerId: string; providerUserId: string; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html index 66c42678442..f203b7a934a 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html @@ -55,7 +55,7 @@ > {{ "providerUsersNeedConfirmed" | i18n }} - + diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts index 9be09c295ae..482d2c881c1 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts @@ -2,8 +2,8 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { authGuard } from "@bitwarden/angular/auth/guards"; -import { AnonLayoutWrapperComponent } from "@bitwarden/auth/angular"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { AnonLayoutWrapperComponent } from "@bitwarden/components"; import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component"; import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component"; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index 597acb0d4f0..01f1facfc15 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -4,7 +4,7 @@ import { NgModule } from "@angular/core"; import { FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CardComponent, SearchModule } from "@bitwarden/components"; +import { CardComponent, ScrollLayoutDirective, SearchModule } from "@bitwarden/components"; import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component"; import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing"; import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component"; @@ -53,6 +53,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr ScrollingModule, VerifyBankAccountComponent, CardComponent, + ScrollLayoutDirective, PaymentComponent, ], declarations: [ diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.ts index 8d87b82bb88..47c30490af3 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.ts @@ -1,7 +1,7 @@ import { Component } from "@angular/core"; import { Params } from "@angular/router"; -import { BitwardenLogo } from "@bitwarden/auth/angular"; +import { Icons } from "@bitwarden/components"; import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.component"; @Component({ @@ -10,7 +10,7 @@ import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept standalone: false, }) export class SetupProviderComponent extends BaseAcceptComponent { - protected logo = BitwardenLogo; + protected logo = Icons.BitwardenLogo; failedShortMessage = "inviteAcceptFailedShort"; failedMessage = "inviteAcceptFailed"; diff --git a/bitwarden_license/bit-web/src/app/app-routing.module.ts b/bitwarden_license/bit-web/src/app/app-routing.module.ts index 3f2803695eb..dc6f417c290 100644 --- a/bitwarden_license/bit-web/src/app/app-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/app-routing.module.ts @@ -2,7 +2,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { unauthGuardFn } from "@bitwarden/angular/auth/guards"; -import { AnonLayoutWrapperComponent } from "@bitwarden/auth/angular"; +import { AnonLayoutWrapperComponent } from "@bitwarden/components"; import { deepLinkGuard } from "@bitwarden/web-vault/app/auth/guards/deep-link/deep-link.guard"; import { RouteDataProperties } from "@bitwarden/web-vault/app/core"; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts index e74682f64fe..c7d82c3ec09 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts @@ -1,4 +1,3 @@ -import { BasePortalOutlet } from "@angular/cdk/portal"; import { Component, Inject, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; @@ -33,8 +32,7 @@ export const openCreateClientDialog = ( dialogService: DialogService, dialogConfig: DialogConfig< CreateClientDialogParams, - DialogRef, - BasePortalOutlet + DialogRef >, ) => dialogService.open( diff --git a/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts index 056339b6fb7..0634c891a05 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts @@ -3,7 +3,6 @@ import { ActivatedRoute, Params, Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { filter, map, switchMap } from "rxjs/operators"; -import { BitwardenLogo } from "@bitwarden/auth/angular"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -13,6 +12,7 @@ import { StateProvider } from "@bitwarden/common/platform/state"; import { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { ProviderKey } from "@bitwarden/common/types/key"; +import { Icons } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service"; import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.component"; @@ -22,7 +22,7 @@ import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept standalone: false, }) export class SetupBusinessUnitComponent extends BaseAcceptComponent { - protected bitwardenLogo = BitwardenLogo; + protected bitwardenLogo = Icons.BitwardenLogo; failedMessage = "emergencyInviteAcceptFailed"; failedShortMessage = "emergencyInviteAcceptFailedShort"; 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 31eb54d6110..0200e206327 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 @@ -36,7 +36,7 @@ - {{ "members" | i18n }} + {{ "members" | i18n }} {{ "groups" | i18n }} {{ "collections" | i18n }} {{ "items" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/response/member-access-report.response.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/response/member-access-report.response.ts index 959b70b9729..c500c6c0aec 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/response/member-access-report.response.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/response/member-access-report.response.ts @@ -2,7 +2,15 @@ import { BaseResponse } from "@bitwarden/common/models/response/base.response"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { Guid } from "@bitwarden/common/types/guid"; -export class MemberAccessDetails extends BaseResponse { +export class MemberAccessResponse extends BaseResponse { + userName: string; + email: string; + twoFactorEnabled: boolean; + accountRecoveryEnabled: boolean; + userGuid: Guid; + usesKeyConnector: boolean; + + cipherIds: Guid[] = []; collectionId: string; groupId: string; groupName: string; @@ -14,6 +22,14 @@ export class MemberAccessDetails extends BaseResponse { constructor(response: any) { super(response); + this.userName = this.getResponseProperty("UserName"); + this.email = this.getResponseProperty("Email"); + this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled"); + this.accountRecoveryEnabled = this.getResponseProperty("AccountRecoveryEnabled"); + this.userGuid = this.getResponseProperty("UserGuid"); + this.usesKeyConnector = this.getResponseProperty("UsesKeyConnector"); + + this.cipherIds = this.getResponseProperty("CipherIds") || []; this.groupId = this.getResponseProperty("GroupId"); this.collectionId = this.getResponseProperty("CollectionId"); this.groupName = this.getResponseProperty("GroupName"); @@ -24,34 +40,3 @@ export class MemberAccessDetails extends BaseResponse { this.manage = this.getResponseProperty("Manage"); } } - -export class MemberAccessResponse extends BaseResponse { - userName: string; - email: string; - twoFactorEnabled: boolean; - accountRecoveryEnabled: boolean; - collectionsCount: number; - groupsCount: number; - totalItemCount: number; - accessDetails: MemberAccessDetails[] = []; - userGuid: Guid; - usesKeyConnector: boolean; - - constructor(response: any) { - super(response); - this.userName = this.getResponseProperty("UserName"); - this.email = this.getResponseProperty("Email"); - this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled"); - this.accountRecoveryEnabled = this.getResponseProperty("AccountRecoveryEnabled"); - this.collectionsCount = this.getResponseProperty("CollectionsCount"); - this.groupsCount = this.getResponseProperty("GroupsCount"); - this.totalItemCount = this.getResponseProperty("TotalItemCount"); - this.userGuid = this.getResponseProperty("UserGuid"); - this.usesKeyConnector = this.getResponseProperty("UsesKeyConnector"); - - const details = this.getResponseProperty("AccessDetails"); - if (details != null) { - this.accessDetails = details.map((o: any) => new MemberAccessDetails(o)); - } - } -} diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.mock.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.mock.ts index b07e4946ca7..ebf2b9abfc8 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.mock.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.mock.ts @@ -1,9 +1,7 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { Guid } from "@bitwarden/common/types/guid"; -import { - MemberAccessDetails, - MemberAccessResponse, -} from "../response/member-access-report.response"; +import { MemberAccessResponse } from "../response/member-access-report.response"; export const memberAccessReportsMock: MemberAccessResponse[] = [ { @@ -11,223 +9,290 @@ export const memberAccessReportsMock: MemberAccessResponse[] = [ email: "sjohnson@email.com", twoFactorEnabled: true, accountRecoveryEnabled: true, - groupsCount: 2, - collectionsCount: 4, - totalItemCount: 20, - userGuid: "1234", + userGuid: "1001" as Guid, usesKeyConnector: false, - accessDetails: [ - { - groupId: "", - collectionId: "c1", - collectionName: new EncString("Collection 1"), - groupName: "", - itemCount: 10, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "", - collectionId: "c2", - collectionName: new EncString("Collection 2"), - groupName: "", - itemCount: 20, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "", - collectionId: "c3", - collectionName: new EncString("Collection 3"), - groupName: "", - itemCount: 30, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g1", - collectionId: "c1", - collectionName: new EncString("Collection 1"), - groupName: "Group 1", - itemCount: 30, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g1", - collectionId: "c2", - collectionName: new EncString("Collection 2"), - groupName: "Group 1", - itemCount: 20, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - ], - } as MemberAccessResponse, + groupId: "", + collectionId: "c1", + collectionName: new EncString("Collection 1"), + groupName: "", + itemCount: 10, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Sarah Johnson", + email: "sjohnson@email.com", + twoFactorEnabled: true, + accountRecoveryEnabled: true, + userGuid: "1001" as Guid, + usesKeyConnector: false, + groupId: "", + collectionId: "c2", + collectionName: new EncString("Collection 2"), + groupName: "", + itemCount: 20, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Sarah Johnson", + email: "sjohnson@email.com", + twoFactorEnabled: true, + accountRecoveryEnabled: true, + userGuid: "1001" as Guid, + usesKeyConnector: false, + groupId: "", + collectionId: "c3", + collectionName: new EncString("Collection 3"), + groupName: "", + itemCount: 30, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Sarah Johnson", + email: "sjohnson@email.com", + twoFactorEnabled: true, + accountRecoveryEnabled: true, + userGuid: "1001", + usesKeyConnector: false, + groupId: "g1", + collectionId: "c1", + collectionName: new EncString("Collection 1"), + groupName: "Group 1", + itemCount: 30, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Sarah Johnson", + email: "sjohnson@email.com", + twoFactorEnabled: true, + accountRecoveryEnabled: true, + userGuid: "1001", + usesKeyConnector: false, + groupId: "g1", + collectionId: "c2", + collectionName: new EncString("Collection 2"), + groupName: "Group 1", + itemCount: 20, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, { userName: "James Lull", email: "jlull@email.com", twoFactorEnabled: false, accountRecoveryEnabled: false, - groupsCount: 2, - collectionsCount: 4, - totalItemCount: 20, - userGuid: "1234", + userGuid: "2001", usesKeyConnector: false, - accessDetails: [ - { - groupId: "g4", - collectionId: "c4", - groupName: "Group 4", - collectionName: new EncString("Collection 4"), - itemCount: 5, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g4", - collectionId: "c5", - groupName: "Group 4", - collectionName: new EncString("Collection 5"), - itemCount: 15, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "", - collectionId: "c4", - groupName: "", - collectionName: new EncString("Collection 4"), - itemCount: 5, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "", - collectionId: "c5", - groupName: "", - collectionName: new EncString("Collection 5"), - itemCount: 15, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - ], - } as MemberAccessResponse, + groupId: "g4", + collectionId: "c4", + groupName: "Group 4", + collectionName: new EncString("Collection 4"), + itemCount: 5, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "James Lull", + email: "jlull@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "2001", + usesKeyConnector: false, + groupId: "g4", + collectionId: "c5", + groupName: "Group 4", + collectionName: new EncString("Collection 5"), + itemCount: 15, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "James Lull", + email: "jlull@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "2001", + usesKeyConnector: false, + groupId: "", + collectionId: "c4", + groupName: "", + collectionName: new EncString("Collection 4"), + itemCount: 5, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "James Lull", + email: "jlull@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "2001", + usesKeyConnector: false, + groupId: "", + collectionId: "c5", + groupName: "", + collectionName: new EncString("Collection 5"), + itemCount: 15, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, { userName: "Beth Williams", email: "bwilliams@email.com", twoFactorEnabled: true, accountRecoveryEnabled: true, - groupsCount: 2, - collectionsCount: 4, - totalItemCount: 20, - userGuid: "1234", + userGuid: "3001", usesKeyConnector: false, - accessDetails: [ - { - groupId: "", - collectionId: "c6", - groupName: "", - collectionName: new EncString("Collection 6"), - itemCount: 25, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g6", - collectionId: "c4", - groupName: "Group 6", - collectionName: new EncString("Collection 4"), - itemCount: 35, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - ], - } as MemberAccessResponse, + groupId: "", + collectionId: "c6", + groupName: "", + collectionName: new EncString("Collection 6"), + itemCount: 25, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Beth Williams", + email: "bwilliams@email.com", + twoFactorEnabled: true, + accountRecoveryEnabled: true, + userGuid: "3001", + usesKeyConnector: false, + groupId: "g6", + collectionId: "c4", + groupName: "Group 6", + collectionName: new EncString("Collection 4"), + itemCount: 35, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, { userName: "Ray Williams", email: "rwilliams@email.com", twoFactorEnabled: false, accountRecoveryEnabled: false, - groupsCount: 2, - collectionsCount: 4, - totalItemCount: 20, - userGuid: "1234", + userGuid: "4000", usesKeyConnector: false, - accessDetails: [ - { - groupId: "", - collectionId: "c7", - groupName: "", - collectionName: new EncString("Collection 7"), - itemCount: 8, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "", - collectionId: "c8", - groupName: "", - collectionName: new EncString("Collection 8"), - itemCount: 12, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "", - collectionId: "c9", - groupName: "", - collectionName: new EncString("Collection 9"), - itemCount: 16, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g9", - collectionId: "c7", - groupName: "Group 9", - collectionName: new EncString("Collection 7"), - itemCount: 8, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g10", - collectionId: "c8", - groupName: "Group 10", - collectionName: new EncString("Collection 8"), - itemCount: 12, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g11", - collectionId: "c9", - groupName: "Group 11", - collectionName: new EncString("Collection 9"), - itemCount: 16, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - ], - } as MemberAccessResponse, + groupId: "", + collectionId: "c7", + groupName: "", + collectionName: new EncString("Collection 7"), + itemCount: 8, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Ray Williams", + email: "rwilliams@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "4000", + usesKeyConnector: false, + groupId: "", + collectionId: "c8", + groupName: "", + collectionName: new EncString("Collection 8"), + itemCount: 12, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Ray Williams", + email: "rwilliams@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "4000", + usesKeyConnector: false, + groupId: "", + collectionId: "c9", + groupName: "", + collectionName: new EncString("Collection 9"), + itemCount: 16, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Ray Williams", + email: "rwilliams@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "4000", + usesKeyConnector: false, + groupId: "g9", + collectionId: "c7", + groupName: "Group 9", + collectionName: new EncString("Collection 7"), + itemCount: 8, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Ray Williams", + email: "rwilliams@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "4000", + usesKeyConnector: false, + groupId: "g10", + collectionId: "c8", + groupName: "Group 10", + collectionName: new EncString("Collection 8"), + itemCount: 12, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Ray Williams", + email: "rwilliams@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "4000", + usesKeyConnector: false, + groupId: "g11", + collectionId: "c9", + groupName: "Group 11", + collectionName: new EncString("Collection 9"), + itemCount: 16, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, ]; export const memberAccessWithoutAccessDetailsReportsMock: MemberAccessResponse[] = [ @@ -236,34 +301,33 @@ export const memberAccessWithoutAccessDetailsReportsMock: MemberAccessResponse[] email: "asmith@email.com", twoFactorEnabled: true, accountRecoveryEnabled: true, - groupsCount: 2, - collectionsCount: 4, - totalItemCount: 20, - userGuid: "1234", + userGuid: "1234" as Guid, usesKeyConnector: false, - accessDetails: [ - { - groupId: "", - collectionId: "c1", - collectionName: new EncString("Collection 1"), - groupName: "Alice Group 1", - itemCount: 10, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - ], - } as MemberAccessResponse, + groupId: "", + collectionId: "c1", + collectionName: new EncString("Collection 1"), + groupName: "Alice Group 1", + itemCount: 10, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, { userName: "Robert Brown", email: "rbrown@email.com", twoFactorEnabled: false, accountRecoveryEnabled: false, - groupsCount: 2, - collectionsCount: 4, - totalItemCount: 20, - userGuid: "5678", + userGuid: "5678" as Guid, usesKeyConnector: false, - accessDetails: [] as MemberAccessDetails[], - } as MemberAccessResponse, + groupId: "", + collectionId: "c1", + collectionName: new EncString("Collection 1"), + groupName: "", + itemCount: 10, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, ]; diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts index e6efac83616..ad388cfed04 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts @@ -35,36 +35,36 @@ describe("ImportService", () => { { name: "Sarah Johnson", email: "sjohnson@email.com", - collectionsCount: 4, - groupsCount: 2, - itemsCount: 20, + collectionsCount: 3, + groupsCount: 1, + itemsCount: 0, userGuid: expect.any(String), usesKeyConnector: expect.any(Boolean), }, { name: "James Lull", email: "jlull@email.com", - collectionsCount: 4, - groupsCount: 2, - itemsCount: 20, + collectionsCount: 2, + groupsCount: 1, + itemsCount: 0, userGuid: expect.any(String), usesKeyConnector: expect.any(Boolean), }, { name: "Beth Williams", email: "bwilliams@email.com", - collectionsCount: 4, - groupsCount: 2, - itemsCount: 20, + collectionsCount: 2, + groupsCount: 1, + itemsCount: 0, userGuid: expect.any(String), usesKeyConnector: expect.any(Boolean), }, { name: "Ray Williams", email: "rwilliams@email.com", - collectionsCount: 4, - groupsCount: 2, - itemsCount: 20, + collectionsCount: 3, + groupsCount: 3, + itemsCount: 0, userGuid: expect.any(String), usesKeyConnector: expect.any(Boolean), }, @@ -82,8 +82,8 @@ describe("ImportService", () => { (item) => (item.name === "Sarah Johnson" && item.group === "Group 1" && - item.totalItems === "20") || - (item.name === "James Lull" && item.group === "Group 4" && item.totalItems === "5"), + item.totalItems === "0") || + (item.name === "James Lull" && item.group === "Group 4" && item.totalItems === "0"), ) .map((item) => ({ name: item.name, @@ -102,7 +102,7 @@ describe("ImportService", () => { twoStepLogin: "memberAccessReportTwoFactorEnabledTrue", accountRecovery: "memberAccessReportAuthenticationEnabledTrue", group: "Group 1", - totalItems: "20", + totalItems: "0", }), expect.objectContaining({ email: "jlull@email.com", @@ -110,7 +110,7 @@ describe("ImportService", () => { twoStepLogin: "memberAccessReportTwoFactorEnabledFalse", accountRecovery: "memberAccessReportAuthenticationEnabledFalse", group: "Group 4", - totalItems: "5", + totalItems: "0", }), ]), ); @@ -131,7 +131,7 @@ describe("ImportService", () => { twoStepLogin: "memberAccessReportTwoFactorEnabledTrue", accountRecovery: "memberAccessReportAuthenticationEnabledTrue", group: "Alice Group 1", - totalItems: "10", + totalItems: "0", }), expect.objectContaining({ email: "rbrown@email.com", diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts index 029dce8a404..0039788709e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts @@ -5,13 +5,13 @@ import { Injectable } from "@angular/core"; import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { Guid, OrganizationId } from "@bitwarden/common/types/guid"; import { getPermissionList, convertToPermission, } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/access-selector"; -import { MemberAccessDetails } from "../response/member-access-report.response"; +import { MemberAccessResponse } from "../response/member-access-report.response"; import { MemberAccessExportItem } from "../view/member-access-export.view"; import { MemberAccessReportView } from "../view/member-access-report.view"; @@ -34,15 +34,44 @@ export class MemberAccessReportService { organizationId: OrganizationId, ): Promise { const memberAccessData = await this.reportApiService.getMemberAccessData(organizationId); - const memberAccessReportViewCollection = memberAccessData.map((userData) => ({ - name: userData.userName, - email: userData.email, - collectionsCount: userData.collectionsCount, - groupsCount: userData.groupsCount, - itemsCount: userData.totalItemCount, - userGuid: userData.userGuid, - usesKeyConnector: userData.usesKeyConnector, - })); + + // group member access data by userGuid + const userMap = new Map(); + memberAccessData.forEach((userData) => { + const userGuid = userData.userGuid; + if (!userMap.has(userGuid)) { + userMap.set(userGuid, []); + } + userMap.get(userGuid)?.push(userData); + }); + + // aggregate user data + const memberAccessReportViewCollection: MemberAccessReportView[] = []; + userMap.forEach((userDataArray, userGuid) => { + const collectionCount = this.getDistinctCount( + userDataArray.map((data) => data.collectionId).filter((id) => !!id), + ); + const groupCount = this.getDistinctCount( + userDataArray.map((data) => data.groupId).filter((id) => !!id), + ); + const itemsCount = this.getDistinctCount( + userDataArray + .flatMap((data) => data.cipherIds) + .filter((id) => id !== "00000000-0000-0000-0000-000000000000"), + ); + const aggregatedData = { + userGuid: userGuid, + name: userDataArray[0].userName, + email: userDataArray[0].email, + collectionsCount: collectionCount, + groupsCount: groupCount, + itemsCount: itemsCount, + usesKeyConnector: userDataArray.some((data) => data.usesKeyConnector), + }; + + memberAccessReportViewCollection.push(aggregatedData); + }); + return memberAccessReportViewCollection; } @@ -50,13 +79,8 @@ export class MemberAccessReportService { organizationId: OrganizationId, ): Promise { const memberAccessReports = await this.reportApiService.getMemberAccessData(organizationId); - const collectionNames = memberAccessReports.flatMap((item) => - item.accessDetails.map((dtl) => { - if (dtl.collectionName) { - return dtl.collectionName.encryptedString; - } - }), - ); + const collectionNames = memberAccessReports.map((item) => item.collectionName.encryptedString); + const collectionNameMap = new Map(collectionNames.map((col) => [col, ""])); for await (const key of collectionNameMap.keys()) { const decrypted = new EncString(key); @@ -64,56 +88,35 @@ export class MemberAccessReportService { collectionNameMap.set(key, decrypted.decryptedValue); } - const exportItems = memberAccessReports.flatMap((report) => { - // to include users without access details - // which means a user has no groups, collections or items - if (report.accessDetails.length === 0) { - return [ - { - email: report.email, - name: report.userName, - twoStepLogin: report.twoFactorEnabled - ? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue") - : this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"), - accountRecovery: report.accountRecoveryEnabled - ? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue") - : this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"), - group: this.i18nService.t("memberAccessReportNoGroup"), - collection: this.i18nService.t("memberAccessReportNoCollection"), - collectionPermission: this.i18nService.t("memberAccessReportNoCollectionPermission"), - totalItems: "0", - }, - ]; - } - const userDetails = report.accessDetails.map((detail) => { - const collectionName = collectionNameMap.get(detail.collectionName.encryptedString); - return { - email: report.email, - name: report.userName, - twoStepLogin: report.twoFactorEnabled - ? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue") - : this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"), - accountRecovery: report.accountRecoveryEnabled - ? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue") - : this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"), - group: detail.groupName - ? detail.groupName - : this.i18nService.t("memberAccessReportNoGroup"), - collection: collectionName - ? collectionName - : this.i18nService.t("memberAccessReportNoCollection"), - collectionPermission: detail.collectionId - ? this.getPermissionText(detail) - : this.i18nService.t("memberAccessReportNoCollectionPermission"), - totalItems: detail.itemCount.toString(), - }; - }); - return userDetails; + const exportItems = memberAccessReports.map((report) => { + const collectionName = collectionNameMap.get(report.collectionName.encryptedString); + return { + email: report.email, + name: report.userName, + twoStepLogin: report.twoFactorEnabled + ? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue") + : this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"), + accountRecovery: report.accountRecoveryEnabled + ? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue") + : this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"), + group: report.groupName + ? report.groupName + : this.i18nService.t("memberAccessReportNoGroup"), + collection: collectionName + ? collectionName + : this.i18nService.t("memberAccessReportNoCollection"), + collectionPermission: report.collectionId + ? this.getPermissionText(report) + : this.i18nService.t("memberAccessReportNoCollectionPermission"), + totalItems: report.cipherIds + .filter((_) => _ != "00000000-0000-0000-0000-000000000000") + .length.toString(), + }; }); return exportItems.flat(); } - private getPermissionText(accessDetails: MemberAccessDetails): string { + private getPermissionText(accessDetails: MemberAccessResponse): string { const permissionList = getPermissionList(); const collectionSelectionView = new CollectionAccessSelectionView({ id: accessDetails.groupId ?? accessDetails.collectionId, @@ -125,4 +128,9 @@ export class MemberAccessReportService { permissionList.find((p) => p.perm === convertToPermission(collectionSelectionView))?.labelId, ); } + + private getDistinctCount(items: T[]): number { + const uniqueItems = new Set(items); + return uniqueItems.size; + } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.html index 3f107486e27..cbac54fd7c6 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.html @@ -1,5 +1,5 @@
-
+

{{ "projectPeopleDescription" | i18n }}

diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.html index 5d22358277f..a3914ac9cf2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.html @@ -1,5 +1,5 @@ -
+

{{ "projectMachineAccountsDescription" | i18n }}

diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts index 815ea1dc60c..250e0870ecf 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts @@ -2,9 +2,9 @@ // @ts-strict-ignore import { Component, Inject, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { Router } from "@angular/router"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogRef, DIALOG_DATA, BitValidators, ToastService } from "@bitwarden/components"; import { ServiceAccountView } from "../../models/view/service-account.view"; @@ -46,8 +46,8 @@ export class ServiceAccountDialogComponent implements OnInit { @Inject(DIALOG_DATA) private data: ServiceAccountOperation, private serviceAccountService: ServiceAccountService, private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, private toastService: ToastService, + private router: Router, ) {} async ngOnInit() { @@ -87,8 +87,17 @@ export class ServiceAccountDialogComponent implements OnInit { let serviceAccountMessage: string; if (this.data.operation == OperationType.Add) { - await this.serviceAccountService.create(this.data.organizationId, serviceAccountView); + const newServiceAccount = await this.serviceAccountService.create( + this.data.organizationId, + serviceAccountView, + ); serviceAccountMessage = this.i18nService.t("machineAccountCreated"); + await this.router.navigate([ + "sm", + this.data.organizationId, + "machine-accounts", + newServiceAccount.id, + ]); } else { await this.serviceAccountService.update( this.data.serviceAccountId, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.html index 96f7ae4d2bf..49cafeccc3b 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.html @@ -1,5 +1,5 @@ -
+

{{ "machineAccountPeopleDescription" | i18n }}

diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html index 623542bd33d..ab7d90ef078 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html @@ -1,5 +1,5 @@ -
+

{{ "machineAccountProjectsDescription" | i18n }}

@@ -9,7 +9,7 @@ [addButtonMode]="true" [items]="potentialGrantees" [label]="'projects' | i18n" - [hint]="'newSaSelectAccess' | i18n" + [hint]="'typeOrSelectProjects' | i18n" [columnTitle]="'projects' | i18n" [emptyMessage]="'serviceAccountEmptyProjectAccesspolicies' | i18n" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.service.ts index c5d4f979ef4..19382793673 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.service.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.service.ts @@ -91,7 +91,10 @@ export class ServiceAccountService { ); } - async create(organizationId: string, serviceAccountView: ServiceAccountView) { + async create( + organizationId: string, + serviceAccountView: ServiceAccountView, + ): Promise { const orgKey = await this.getOrganizationKey(organizationId); const request = await this.getServiceAccountRequest(orgKey, serviceAccountView); const r = await this.apiService.send( @@ -101,9 +104,14 @@ export class ServiceAccountService { true, true, ); - this._serviceAccount.next( - await this.createServiceAccountView(orgKey, new ServiceAccountResponse(r)), + + const serviceAccount = await this.createServiceAccountView( + orgKey, + new ServiceAccountResponse(r), ); + this._serviceAccount.next(serviceAccount); + + return serviceAccount; } async delete(serviceAccounts: ServiceAccountView[]): Promise { diff --git a/libs/angular/src/auth/guards/index.ts b/libs/angular/src/auth/guards/index.ts index 026848c4b08..8a4d0be8167 100644 --- a/libs/angular/src/auth/guards/index.ts +++ b/libs/angular/src/auth/guards/index.ts @@ -1,6 +1,6 @@ export * from "./auth.guard"; export * from "./active-auth.guard"; export * from "./lock.guard"; -export * from "./redirect.guard"; +export * from "./redirect/redirect.guard"; export * from "./tde-decryption-required.guard"; export * from "./unauth.guard"; diff --git a/libs/angular/src/auth/guards/redirect/README.md b/libs/angular/src/auth/guards/redirect/README.md new file mode 100644 index 00000000000..b7977a553b1 --- /dev/null +++ b/libs/angular/src/auth/guards/redirect/README.md @@ -0,0 +1,53 @@ +# Redirect Guard + +The `redirectGuard` redirects the user based on their `AuthenticationStatus`. It is applied to the root route (`/`). + +
+ +### Order of Operations + +The `redirectGuard` will redirect the user based on the following checks, _in order_: + +- **`AuthenticationStatus.LoggedOut`** → redirect to `/login` +- **`AuthenticationStatus.Unlocked`** → redirect to `/vault` +- **`AuthenticationStatus.Locked`** + - **TDE Locked State** → redirect to `/login-initiated` + - A user is in a TDE Locked State if they meet all 3 of the following conditions + 1. Auth status is `Locked` + 2. TDE is enabled + 3. User has never had a user key (that is, user has not unlocked/decrypted yet) + - **Standard Locked State** → redirect to `/lock` + +
+ +| Order | AuthenticationStatus | Redirect To | +| ----- | ------------------------------------------------------------------------------- | ------------------ | +| 1 | `LoggedOut` | `/login` | +| 2 | `Unlocked` | `/vault` | +| 3 | **TDE Locked State**
`Locked` +
`tdeEnabled` +
`!everHadUserKey` | `/login-initiated` | +| 4 | **Standard Locked State**
`Locked` | `/lock` | + +
+ +### Default Routes and Route Overrides + +The default redirect routes are mapped to object properties: + +```typescript +const defaultRoutes: RedirectRoutes = { + loggedIn: "/vault", + loggedOut: "/login", + locked: "/lock", + notDecrypted: "/login-initiated", +}; +``` + +But when applying the guard to the root route, the developer can override specific redirect routes by passing in a custom object. This is useful for subtle differences in client-specific routing: + +```typescript +// app-routing.module.ts (Browser Extension) +{ + path: "", + canActivate: [redirectGuard({ loggedIn: "/tabs/current"})], +} +``` diff --git a/libs/angular/src/auth/guards/redirect.guard.ts b/libs/angular/src/auth/guards/redirect/redirect.guard.ts similarity index 86% rename from libs/angular/src/auth/guards/redirect.guard.ts rename to libs/angular/src/auth/guards/redirect/redirect.guard.ts index b893614b405..45e552639c8 100644 --- a/libs/angular/src/auth/guards/redirect.guard.ts +++ b/libs/angular/src/auth/guards/redirect/redirect.guard.ts @@ -25,12 +25,14 @@ const defaultRoutes: RedirectRoutes = { }; /** - * Guard that consolidates all redirection logic, should be applied to root route. + * Redirects the user to the appropriate route based on their `AuthenticationStatus`. + * This guard should be applied to the root route. * * TODO: This should return Observable once we can get rid of all the promises */ export function redirectGuard(overrides: Partial = {}): CanActivateFn { const routes = { ...defaultRoutes, ...overrides }; + return async (route) => { const authService = inject(AuthService); const keyService = inject(KeyService); @@ -41,16 +43,21 @@ export function redirectGuard(overrides: Partial = {}): CanActiv const authStatus = await authService.getAuthStatus(); + // Logged Out if (authStatus === AuthenticationStatus.LoggedOut) { return router.createUrlTree([routes.loggedOut], { queryParams: route.queryParams }); } + // Unlocked if (authStatus === AuthenticationStatus.Unlocked) { return router.createUrlTree([routes.loggedIn], { queryParams: route.queryParams }); } - // If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the - // login decryption options component. + // Locked: TDE Locked State + // - If user meets all 3 of the following conditions: + // 1. Auth status is Locked + // 2. TDE is enabled + // 3. User has never had a user key (has not decrypted yet) const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$); const userId = await firstValueFrom(accountService.activeAccount$.pipe(getUserId)); const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(userId)); @@ -64,6 +71,7 @@ export function redirectGuard(overrides: Partial = {}): CanActiv return router.createUrlTree([routes.notDecrypted], { queryParams: route.queryParams }); } + // Locked: Standard Locked State if (authStatus === AuthenticationStatus.Locked) { return router.createUrlTree([routes.locked], { queryParams: route.queryParams }); } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index e1f806c4d3e..c1c4844a61d 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -14,8 +14,6 @@ import { // 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 { - AnonLayoutWrapperDataService, - DefaultAnonLayoutWrapperDataService, DefaultLoginApprovalComponentService, DefaultLoginComponentService, DefaultLoginDecryptionOptionsService, @@ -296,10 +294,15 @@ import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service"; import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks"; -import { ToastService } from "@bitwarden/components"; +import { + AnonLayoutWrapperDataService, + DefaultAnonLayoutWrapperDataService, + ToastService, +} from "@bitwarden/components"; import { GeneratorHistoryService, LocalGeneratorHistoryService, @@ -678,6 +681,11 @@ const safeProviders: SafeProvider[] = [ KdfConfigService, ], }), + safeProvider({ + provide: RestrictedItemTypesService, + useClass: RestrictedItemTypesService, + deps: [ConfigService, AccountService, OrganizationServiceAbstraction, PolicyServiceAbstraction], + }), safeProvider({ provide: PasswordStrengthServiceAbstraction, useClass: PasswordStrengthService, diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 5d6343b0b3c..ec79ac9ef18 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -84,7 +84,6 @@ export class AddEditComponent implements OnInit, OnDestroy { showCardNumber = false; showCardCode = false; cipherType = CipherType; - typeOptions: any[]; cardBrandOptions: any[]; cardExpMonthOptions: any[]; identityTitleOptions: any[]; @@ -139,13 +138,6 @@ export class AddEditComponent implements OnInit, OnDestroy { protected sdkService: SdkService, private sshImportPromptService: SshImportPromptService, ) { - this.typeOptions = [ - { name: i18nService.t("typeLogin"), value: CipherType.Login }, - { name: i18nService.t("typeCard"), value: CipherType.Card }, - { name: i18nService.t("typeIdentity"), value: CipherType.Identity }, - { name: i18nService.t("typeSecureNote"), value: CipherType.SecureNote }, - ]; - this.cardBrandOptions = [ { name: "-- " + i18nService.t("select") + " --", value: null }, { name: "Visa", value: "Visa" }, @@ -215,8 +207,6 @@ export class AddEditComponent implements OnInit, OnDestroy { this.writeableCollections = await this.loadCollections(); this.canUseReprompt = await this.passwordRepromptService.enabled(); - - this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey }); } ngOnDestroy() { diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index c34816994be..0679d141bbd 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -8,7 +8,9 @@ import { combineLatest, filter, from, + map, of, + shareReplay, switchMap, takeUntil, } from "rxjs"; @@ -20,6 +22,11 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + isCipherViewRestricted, + RestrictedItemTypesService, +} from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; @Directive() export class VaultItemsComponent implements OnInit, OnDestroy { @@ -35,6 +42,19 @@ export class VaultItemsComponent implements OnInit, OnDestroy { organization: Organization; CipherType = CipherType; + protected itemTypes$ = this.restrictedItemTypesService.restricted$.pipe( + map((restrictedItemTypes) => + // Filter out restricted item types + CIPHER_MENU_ITEMS.filter( + (itemType) => + !restrictedItemTypes.some( + (restrictedType) => restrictedType.cipherType === itemType.type, + ), + ), + ), + shareReplay({ bufferSize: 1, refCount: true }), + ); + protected searchPending = false; /** Construct filters as an observable so it can be appended to the cipher stream. */ @@ -62,6 +82,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy { protected searchService: SearchService, protected cipherService: CipherService, protected accountService: AccountService, + protected restrictedItemTypesService: RestrictedItemTypesService, ) { this.subscribeToCiphers(); } @@ -143,18 +164,22 @@ export class VaultItemsComponent implements OnInit, OnDestroy { this._searchText$, this._filter$, of(userId), + this.restrictedItemTypesService.restricted$, ]), ), - switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId]) => { + switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId, restricted]) => { let allCiphers = indexedCiphers ?? []; const _failedCiphers = failedCiphers ?? []; allCiphers = [..._failedCiphers, ...allCiphers]; + const restrictedTypeFilter = (cipher: CipherView) => + !isCipherViewRestricted(cipher, restricted); + return this.searchService.searchCiphers( userId, searchText, - [filter, this.deletedFilter], + [filter, this.deletedFilter, restrictedTypeFilter], allCiphers, ); }), diff --git a/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts index 30bbd153c5e..99d7f3934ff 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts @@ -1,14 +1,18 @@ import { Injectable, inject } from "@angular/core"; import { Observable, combineLatest, from, of } from "rxjs"; -import { catchError, map } from "rxjs/operators"; +import { catchError, switchMap } from "rxjs/operators"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.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 { PinServiceAbstraction } from "@bitwarden/auth/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { BiometricStateService } from "@bitwarden/key-management"; import { DefaultSingleNudgeService } from "../default-single-nudge.service"; import { NudgeStatus, NudgeType } from "../nudges.service"; @@ -21,6 +25,9 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService { private logService = inject(LogService); private pinService = inject(PinServiceAbstraction); private vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService); + private biometricStateService = inject(BiometricStateService); + private policyService = inject(PolicyService); + private organizationService = inject(OrganizationService); nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe( @@ -36,16 +43,45 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService { this.getNudgeStatus$(nudgeType, userId), of(Date.now() - THIRTY_DAYS_MS), from(this.pinService.isPinSet(userId)), - from(this.vaultTimeoutSettingsService.isBiometricLockSet(userId)), + this.biometricStateService.biometricUnlockEnabled$, + this.organizationService.organizations$(userId), + this.policyService.policiesByType$(PolicyType.RemoveUnlockWithPin, userId), ]).pipe( - map(([profileCreationDate, status, profileCutoff, isPinSet, isBiometricLockSet]) => { - const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff; - const hideNudge = profileOlderThanCutoff || isPinSet || isBiometricLockSet; - return { - hasBadgeDismissed: status.hasBadgeDismissed || hideNudge, - hasSpotlightDismissed: status.hasSpotlightDismissed || hideNudge, - }; - }), + switchMap( + async ([ + profileCreationDate, + status, + profileCutoff, + isPinSet, + biometricUnlockEnabled, + organizations, + policies, + ]) => { + const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff; + + const hasOrgWithRemovePinPolicyOn = organizations.some((org) => { + return policies.some( + (p) => p.type === PolicyType.RemoveUnlockWithPin && p.organizationId === org.id, + ); + }); + + const hideNudge = + profileOlderThanCutoff || + isPinSet || + biometricUnlockEnabled || + hasOrgWithRemovePinPolicyOn; + + const acctSecurityNudgeStatus = { + hasBadgeDismissed: status.hasBadgeDismissed || hideNudge, + hasSpotlightDismissed: status.hasSpotlightDismissed || hideNudge, + }; + + if (isPinSet || biometricUnlockEnabled || hasOrgWithRemovePinPolicyOn) { + await this.setNudgeStatus(nudgeType, acctSecurityNudgeStatus, userId); + } + return acctSecurityNudgeStatus; + }, + ), ); } } diff --git a/libs/angular/src/vault/services/nudges.service.spec.ts b/libs/angular/src/vault/services/nudges.service.spec.ts index f18d846232c..bf84674c669 100644 --- a/libs/angular/src/vault/services/nudges.service.spec.ts +++ b/libs/angular/src/vault/services/nudges.service.spec.ts @@ -6,6 +6,8 @@ import { firstValueFrom, of } from "rxjs"; // eslint-disable-next-line no-restricted-imports import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -13,6 +15,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { BiometricStateService } from "@bitwarden/key-management"; import { FakeStateProvider, mockAccountServiceWith } from "../../../../../libs/common/spec"; @@ -91,6 +94,18 @@ describe("Vault Nudges Service", () => { provide: VaultTimeoutSettingsService, useValue: mock(), }, + { + provide: BiometricStateService, + useValue: mock(), + }, + { + provide: PolicyService, + useValue: mock(), + }, + { + provide: OrganizationService, + useValue: mock(), + }, ], }); }); diff --git a/libs/auth/src/angular/icons/index.ts b/libs/auth/src/angular/icons/index.ts index 0ec92d54547..078e7f764c5 100644 --- a/libs/auth/src/angular/icons/index.ts +++ b/libs/auth/src/angular/icons/index.ts @@ -1,8 +1,4 @@ -export * from "./bitwarden-logo.icon"; -export * from "./bitwarden-shield.icon"; export * from "./devices.icon"; -export * from "./lock.icon"; -export * from "./registration-check-email.icon"; export * from "./user-lock.icon"; export * from "./user-verification-biometrics-fingerprint.icon"; export * from "./wave.icon"; diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index f4f6cc71a42..fc5ffd71e9a 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -1,13 +1,6 @@ /** * This barrel file should only contain Angular exports */ - -// anon layout -export * from "./anon-layout/anon-layout.component"; -export * from "./anon-layout/anon-layout-wrapper.component"; -export * from "./anon-layout/anon-layout-wrapper-data.service"; -export * from "./anon-layout/default-anon-layout-wrapper-data.service"; - // change password export * from "./change-password/change-password.component"; export * from "./change-password/change-password.service.abstraction"; diff --git a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts index 172823f23da..bbdc0106786 100644 --- a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts +++ b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts @@ -30,6 +30,7 @@ import { 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 import { + AnonLayoutWrapperDataService, AsyncActionsModule, ButtonModule, CheckboxModule, @@ -40,8 +41,6 @@ import { } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; - import { LoginDecryptionOptionsService } from "./login-decryption-options.service"; // FIXME: update to use a const object instead of a typescript enum diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index aaff86224ff..5e5d5bde4e3 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -32,6 +32,7 @@ import { 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 import { + AnonLayoutWrapperDataService, AsyncActionsModule, ButtonModule, CheckboxModule, @@ -41,7 +42,6 @@ import { ToastService, } from "@bitwarden/components"; -import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; import { VaultIcon, WaveIcon } from "../icons"; import { LoginComponentService, PasswordPolicies } from "./login-component.service"; diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts index c3a09a897e5..f987083fb01 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts @@ -16,14 +16,13 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.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 { ToastService } from "@bitwarden/components"; +import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components"; import { LoginStrategyServiceAbstraction, LoginSuccessHandlerService, PasswordLoginCredentials, } from "../../../common"; -import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service"; import { InputPasswordComponent, InputPasswordFlow, 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 d8a4ebb2b7d..2545f86f665 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 @@ -14,18 +14,18 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl // 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 { + AnonLayoutWrapperDataService, AsyncActionsModule, ButtonModule, CheckboxModule, FormFieldModule, + Icons, IconModule, LinkModule, } from "@bitwarden/components"; import { LoginEmailService } from "../../../common"; -import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service"; import { RegistrationUserAddIcon } from "../../icons"; -import { RegistrationCheckEmailIcon } from "../../icons/registration-check-email.icon"; import { RegistrationEnvSelectorComponent } from "../registration-env-selector/registration-env-selector.component"; // FIXME: update to use a const object instead of a typescript enum @@ -170,7 +170,7 @@ export class RegistrationStartComponent implements OnInit, OnDestroy { pageTitle: { key: "checkYourEmail", }, - pageIcon: RegistrationCheckEmailIcon, + pageIcon: Icons.RegistrationCheckEmailIcon, }); this.registrationStartStateChange.emit(this.state); }; diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts b/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts index e54e59a988a..d0f0343960a 100644 --- a/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts +++ b/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts @@ -18,6 +18,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl // 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 { + AnonLayoutWrapperData, + AnonLayoutWrapperDataService, AsyncActionsModule, ButtonModule, DialogModule, @@ -34,8 +36,6 @@ import { // eslint-disable-next-line import/no-restricted-paths, no-restricted-imports import { PreloadedEnglishI18nModule } from "../../../../../../apps/web/src/app/core/tests"; import { LoginEmailService } from "../../../common"; -import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service"; -import { AnonLayoutWrapperData } from "../../anon-layout/anon-layout-wrapper.component"; import { RegistrationStartComponent } from "./registration-start.component"; diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts index 76cbfe994a5..00cad105f95 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts @@ -36,9 +36,7 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp import { 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 -import { DialogService, ToastService } from "@bitwarden/components"; - -import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; +import { DialogService, ToastService, AnonLayoutWrapperDataService } from "@bitwarden/components"; import { TwoFactorAuthComponentCacheService } from "./two-factor-auth-component-cache.service"; import { TwoFactorAuthComponentService } from "./two-factor-auth-component.service"; diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts index 315f8121cce..b811d48a48f 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts @@ -41,6 +41,7 @@ import { 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 import { + AnonLayoutWrapperDataService, AsyncActionsModule, ButtonModule, CheckboxModule, @@ -49,7 +50,6 @@ import { ToastService, } from "@bitwarden/components"; -import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; import { TwoFactorAuthAuthenticatorIcon, TwoFactorAuthEmailIcon, diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 44b5e34a4a4..cabde4093c4 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -228,16 +228,6 @@ export abstract class ApiService { request: CipherBulkRestoreRequest, ) => Promise>; - /** - * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. - * This method still exists for backward compatibility with old server versions. - */ - postCipherAttachmentLegacy: (id: string, data: FormData) => Promise; - /** - * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. - * This method still exists for backward compatibility with old server versions. - */ - postCipherAttachmentAdminLegacy: (id: string, data: FormData) => Promise; postCipherAttachment: ( id: string, request: AttachmentRequest, diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts index 51c3d8617ab..4fd91fb19e6 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts @@ -92,6 +92,27 @@ describe("FidoAuthenticatorService", () => { }); describe("createCredential", () => { + describe("Mapping params should handle variations in input formats", () => { + it.each([ + [true, true], + [false, false], + ["false", false], + ["", false], + ["true", true], + ])("requireResidentKey should handle %s as boolean %s", async (input, expected) => { + const params = createParams({ + authenticatorSelection: { requireResidentKey: input as any }, + extensions: { credProps: true }, + }); + + authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); + + const result = await client.createCredential(params, windowReference); + + expect(result.extensions.credProps?.rk).toBe(expected); + }); + }); + describe("input parameters validation", () => { // Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException. it("should throw error if sameOriginWithAncestors is false", async () => { diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.ts b/libs/common/src/platform/services/fido2/fido2-client.service.ts index 2445cd366de..5d5f2a879cb 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.ts @@ -483,11 +483,15 @@ function mapToMakeCredentialParams({ type: credential.type, })) ?? []; + /** + * Quirk: Accounts for the fact that some RP's mistakenly submits 'requireResidentKey' as a string + */ const requireResidentKey = params.authenticatorSelection?.residentKey === "required" || params.authenticatorSelection?.residentKey === "preferred" || (params.authenticatorSelection?.residentKey === undefined && - params.authenticatorSelection?.requireResidentKey === true); + (params.authenticatorSelection?.requireResidentKey === true || + (params.authenticatorSelection?.requireResidentKey as unknown as string) === "true")); const requireUserVerification = params.authenticatorSelection?.userVerification === "required" || diff --git a/libs/common/src/platform/services/fido2/fido2-utils.ts b/libs/common/src/platform/services/fido2/fido2-utils.ts index b9f3c8f8c48..6413eeade04 100644 --- a/libs/common/src/platform/services/fido2/fido2-utils.ts +++ b/libs/common/src/platform/services/fido2/fido2-utils.ts @@ -1,6 +1,45 @@ // FIXME: Update this file to be type safe and remove this and next line +import type { + AssertCredentialResult, + CreateCredentialResult, +} from "../../abstractions/fido2/fido2-client.service.abstraction"; + // @ts-strict-ignore export class Fido2Utils { + static createResultToJson(result: CreateCredentialResult): any { + return { + id: result.credentialId, + rawId: result.credentialId, + response: { + clientDataJSON: result.clientDataJSON, + authenticatorData: result.authData, + transports: result.transports, + publicKey: result.publicKey, + publicKeyAlgorithm: result.publicKeyAlgorithm, + attestationObject: result.attestationObject, + }, + authenticatorAttachment: "platform", + clientExtensionResults: result.extensions, + type: "public-key", + }; + } + + static getResultToJson(result: AssertCredentialResult): any { + return { + id: result.credentialId, + rawId: result.credentialId, + response: { + clientDataJSON: result.clientDataJSON, + authenticatorData: result.authenticatorData, + signature: result.signature, + userHandle: result.userHandle, + }, + authenticatorAttachment: "platform", + clientExtensionResults: {}, + type: "public-key", + }; + } + static bufferToString(bufferSource: BufferSource): string { return Fido2Utils.fromBufferToB64(Fido2Utils.bufferSourceToUint8Array(bufferSource)) .replace(/\+/g, "-") diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index d9f7ba19a6f..e874dae3461 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -212,6 +212,7 @@ export class DefaultSdkService implements SdkService { }, }, privateKey, + signingKey: undefined, }); // We initialize the org crypto even if the org_keys are diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 1971cd86363..a2cc86a57ad 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -639,24 +639,6 @@ export class ApiService implements ApiServiceAbstraction { return new AttachmentUploadDataResponse(r); } - /** - * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. - * This method still exists for backward compatibility with old server versions. - */ - async postCipherAttachmentLegacy(id: string, data: FormData): Promise { - const r = await this.send("POST", "/ciphers/" + id + "/attachment", data, true, true); - return new CipherResponse(r); - } - - /** - * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. - * This method still exists for backward compatibility with old server versions. - */ - async postCipherAttachmentAdminLegacy(id: string, data: FormData): Promise { - const r = await this.send("POST", "/ciphers/" + id + "/attachment-admin", data, true, true); - return new CipherResponse(r); - } - deleteCipherAttachment(id: string, attachmentId: string): Promise { return this.send("DELETE", "/ciphers/" + id + "/attachment/" + attachmentId, null, true, true); } diff --git a/libs/common/src/vault/models/view/card.view.ts b/libs/common/src/vault/models/view/card.view.ts index 2adfbb39e89..dd7f5d6be57 100644 --- a/libs/common/src/vault/models/view/card.view.ts +++ b/libs/common/src/vault/models/view/card.view.ts @@ -157,6 +157,15 @@ export class CardView extends ItemView { return undefined; } - return Object.assign(new CardView(), obj); + const cardView = new CardView(); + + cardView.cardholderName = obj.cardholderName ?? null; + cardView.brand = obj.brand ?? null; + cardView.number = obj.number ?? null; + cardView.expMonth = obj.expMonth ?? null; + cardView.expYear = obj.expYear ?? null; + cardView.code = obj.code ?? null; + + return cardView; } } diff --git a/libs/common/src/vault/models/view/identity.view.ts b/libs/common/src/vault/models/view/identity.view.ts index a75d11efd95..877940e4aea 100644 --- a/libs/common/src/vault/models/view/identity.view.ts +++ b/libs/common/src/vault/models/view/identity.view.ts @@ -169,6 +169,27 @@ export class IdentityView extends ItemView { return undefined; } - return Object.assign(new IdentityView(), obj); + const identityView = new IdentityView(); + + identityView.title = obj.title ?? null; + identityView.firstName = obj.firstName ?? null; + identityView.middleName = obj.middleName ?? null; + identityView.lastName = obj.lastName ?? null; + identityView.address1 = obj.address1 ?? null; + identityView.address2 = obj.address2 ?? null; + identityView.address3 = obj.address3 ?? null; + identityView.city = obj.city ?? null; + identityView.state = obj.state ?? null; + identityView.postalCode = obj.postalCode ?? null; + identityView.country = obj.country ?? null; + identityView.company = obj.company ?? null; + identityView.email = obj.email ?? null; + identityView.phone = obj.phone ?? null; + identityView.ssn = obj.ssn ?? null; + identityView.username = obj.username ?? null; + identityView.passportNumber = obj.passportNumber ?? null; + identityView.licenseNumber = obj.licenseNumber ?? null; + + return identityView; } } diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index 6bdc23f42b1..c6e6ca001e4 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -116,13 +116,18 @@ export class LoginView extends ItemView { return undefined; } - const passwordRevisionDate = - obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); - const uris = obj.uris?.map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || []; + const loginView = new LoginView(); - return Object.assign(new LoginView(), obj, { - passwordRevisionDate, - uris, - }); + loginView.username = obj.username ?? null; + loginView.password = obj.password ?? null; + loginView.passwordRevisionDate = + obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); + loginView.totp = obj.totp ?? null; + loginView.autofillOnPageLoad = obj.autofillOnPageLoad ?? null; + loginView.uris = obj.uris?.map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || []; + // FIDO2 credentials are not decrypted here, they remain encrypted + loginView.fido2Credentials = null; + + return loginView; } } diff --git a/libs/common/src/vault/models/view/secure-note.view.ts b/libs/common/src/vault/models/view/secure-note.view.ts index 075e4dfc520..8e7a6b4652d 100644 --- a/libs/common/src/vault/models/view/secure-note.view.ts +++ b/libs/common/src/vault/models/view/secure-note.view.ts @@ -37,6 +37,9 @@ export class SecureNoteView extends ItemView { return undefined; } - return Object.assign(new SecureNoteView(), obj); + const secureNoteView = new SecureNoteView(); + secureNoteView.type = obj.type ?? null; + + return secureNoteView; } } diff --git a/libs/common/src/vault/models/view/ssh-key.view.ts b/libs/common/src/vault/models/view/ssh-key.view.ts index a3d091e4c07..a83793678dc 100644 --- a/libs/common/src/vault/models/view/ssh-key.view.ts +++ b/libs/common/src/vault/models/view/ssh-key.view.ts @@ -55,10 +55,12 @@ export class SshKeyView extends ItemView { return undefined; } - const keyFingerprint = obj.fingerprint; + const sshKeyView = new SshKeyView(); - return Object.assign(new SshKeyView(), obj, { - keyFingerprint, - }); + sshKeyView.privateKey = obj.privateKey ?? null; + sshKeyView.publicKey = obj.publicKey ?? null; + sshKeyView.keyFingerprint = obj.fingerprint ?? null; + + return sshKeyView; } } diff --git a/libs/common/src/vault/service-utils.ts b/libs/common/src/vault/service-utils.ts index 96ae406fae4..9595434223f 100644 --- a/libs/common/src/vault/service-utils.ts +++ b/libs/common/src/vault/service-utils.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore + import { ITreeNodeObject, TreeNode } from "./models/domain/tree-node"; export class ServiceUtils { diff --git a/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts b/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts index 4dd2f7f7338..10fa1d9580c 100644 --- a/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts +++ b/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts @@ -6,7 +6,6 @@ import { FileUploadApiMethods, FileUploadService, } from "../../../platform/abstractions/file-upload/file-upload.service"; -import { Utils } from "../../../platform/misc/utils"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; @@ -47,18 +46,7 @@ export class CipherFileUploadService implements CipherFileUploadServiceAbstracti this.generateMethods(uploadDataResponse, response, request.adminRequest), ); } catch (e) { - if ( - (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) || - (e as ErrorResponse).statusCode === 405 - ) { - response = await this.legacyServerAttachmentFileUpload( - request.adminRequest, - cipher.id, - encFileName, - encData, - dataEncKey[1], - ); - } else if (e instanceof ErrorResponse) { + if (e instanceof ErrorResponse) { throw new Error((e as ErrorResponse).getSingleMessage()); } else { throw e; @@ -113,50 +101,4 @@ export class CipherFileUploadService implements CipherFileUploadServiceAbstracti } }; } - - /** - * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. - * This method still exists for backward compatibility with old server versions. - */ - async legacyServerAttachmentFileUpload( - admin: boolean, - cipherId: string, - encFileName: EncString, - encData: EncArrayBuffer, - key: EncString, - ) { - const fd = new FormData(); - try { - const blob = new Blob([encData.buffer], { type: "application/octet-stream" }); - fd.append("key", key.encryptedString); - fd.append("data", blob, encFileName.encryptedString); - } catch (e) { - if (Utils.isNode && !Utils.isBrowser) { - fd.append("key", key.encryptedString); - fd.append( - "data", - Buffer.from(encData.buffer) as any, - { - filepath: encFileName.encryptedString, - contentType: "application/octet-stream", - } as any, - ); - } else { - throw e; - } - } - - let response: CipherResponse; - try { - if (admin) { - response = await this.apiService.postCipherAttachmentAdminLegacy(cipherId, fd); - } else { - response = await this.apiService.postCipherAttachmentLegacy(cipherId, fd); - } - } catch (e) { - throw new Error((e as ErrorResponse).getSingleMessage()); - } - - return response; - } } diff --git a/libs/vault/src/services/restricted-item-types.service.spec.ts b/libs/common/src/vault/services/restricted-item-types.service.spec.ts similarity index 89% rename from libs/vault/src/services/restricted-item-types.service.spec.ts rename to libs/common/src/vault/services/restricted-item-types.service.spec.ts index 7ff48f0642b..9b549665184 100644 --- a/libs/vault/src/services/restricted-item-types.service.spec.ts +++ b/libs/common/src/vault/services/restricted-item-types.service.spec.ts @@ -1,4 +1,3 @@ -import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; @@ -49,19 +48,16 @@ describe("RestrictedItemTypesService", () => { fakeAccount = { id: Utils.newGuid() as UserId } as Account; accountService.activeAccount$ = of(fakeAccount); - TestBed.configureTestingModule({ - providers: [ - { provide: PolicyService, useValue: policyService }, - { provide: OrganizationService, useValue: organizationService }, - { provide: AccountService, useValue: accountService }, - { provide: ConfigService, useValue: configService }, - ], - }); - configService.getFeatureFlag$.mockReturnValue(of(true)); organizationService.organizations$.mockReturnValue(of([org1, org2])); policyService.policiesByType$.mockReturnValue(of([])); - service = TestBed.inject(RestrictedItemTypesService); + + service = new RestrictedItemTypesService( + configService, + accountService, + organizationService, + policyService, + ); }); it("emits empty array when feature flag is disabled", async () => { @@ -106,7 +102,6 @@ describe("RestrictedItemTypesService", () => { }); it("returns empty allowViewOrgIds when all orgs restrict the same type", async () => { - configService.getFeatureFlag$.mockReturnValue(of(true)); organizationService.organizations$.mockReturnValue(of([org1, org2])); policyService.policiesByType$.mockReturnValue(of([policyOrg1, policyOrg2])); @@ -117,7 +112,6 @@ describe("RestrictedItemTypesService", () => { }); it("aggregates multiple types and computes allowViewOrgIds correctly", async () => { - configService.getFeatureFlag$.mockReturnValue(of(true)); organizationService.organizations$.mockReturnValue(of([org1, org2])); policyService.policiesByType$.mockReturnValue( of([ diff --git a/libs/vault/src/services/restricted-item-types.service.ts b/libs/common/src/vault/services/restricted-item-types.service.ts similarity index 79% rename from libs/vault/src/services/restricted-item-types.service.ts rename to libs/common/src/vault/services/restricted-item-types.service.ts index b24533fb2f6..63c9577bc09 100644 --- a/libs/vault/src/services/restricted-item-types.service.ts +++ b/libs/common/src/vault/services/restricted-item-types.service.ts @@ -1,4 +1,3 @@ -import { Injectable } from "@angular/core"; import { combineLatest, map, of, Observable } from "rxjs"; import { switchMap, distinctUntilChanged, shareReplay } from "rxjs/operators"; @@ -10,13 +9,13 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; export type RestrictedCipherType = { cipherType: CipherType; allowViewOrgIds: string[]; }; -@Injectable({ providedIn: "root" }) export class RestrictedItemTypesService { /** * Emits an array of RestrictedCipherType objects: @@ -78,3 +77,25 @@ export class RestrictedItemTypesService { private policyService: PolicyService, ) {} } + +/** + * Filter that returns whether a cipher is restricted from being viewed by the user + * Criteria: + * - the cipher's type is restricted by at least one org + * UNLESS + * - the cipher belongs to an organization and that organization does not restrict that type + * OR + * - the cipher belongs to the user's personal vault and at least one other organization does not restrict that type + */ +export function isCipherViewRestricted( + cipher: CipherView, + restrictedTypes: RestrictedCipherType[], +) { + return restrictedTypes.some( + (restrictedType) => + restrictedType.cipherType === cipher.type && + (cipher.organizationId + ? !restrictedType.allowViewOrgIds.includes(cipher.organizationId) + : restrictedType.allowViewOrgIds.length === 0), + ); +} diff --git a/libs/common/src/vault/types/cipher-menu-items.ts b/libs/common/src/vault/types/cipher-menu-items.ts index e88c0457081..7108d0d0bd6 100644 --- a/libs/common/src/vault/types/cipher-menu-items.ts +++ b/libs/common/src/vault/types/cipher-menu-items.ts @@ -19,6 +19,6 @@ export const CIPHER_MENU_ITEMS = Object.freeze([ { type: CipherType.Login, icon: "bwi-globe", labelKey: "typeLogin" }, { type: CipherType.Card, icon: "bwi-credit-card", labelKey: "typeCard" }, { type: CipherType.Identity, icon: "bwi-id-card", labelKey: "typeIdentity" }, - { type: CipherType.SecureNote, icon: "bwi-sticky-note", labelKey: "note" }, + { type: CipherType.SecureNote, icon: "bwi-sticky-note", labelKey: "typeNote" }, { type: CipherType.SshKey, icon: "bwi-key", labelKey: "typeSshKey" }, ] as const) satisfies readonly CipherMenuItem[]; diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper-data.service.ts b/libs/components/src/anon-layout/anon-layout-wrapper-data.service.ts similarity index 100% rename from libs/auth/src/angular/anon-layout/anon-layout-wrapper-data.service.ts rename to libs/components/src/anon-layout/anon-layout-wrapper-data.service.ts diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.html b/libs/components/src/anon-layout/anon-layout-wrapper.component.html similarity index 100% rename from libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.html rename to libs/components/src/anon-layout/anon-layout-wrapper.component.html diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts similarity index 95% rename from libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts rename to libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 69f1dd1be63..ffc601bdf1d 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -4,13 +4,13 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; import { Subject, filter, switchMap, takeUntil, tap } from "rxjs"; -import { AnonLayoutComponent } from "@bitwarden/auth/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { Icon, Translation } from "@bitwarden/components"; + +import { Translation } from "../dialog"; +import { Icon } from "../icon"; import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service"; +import { AnonLayoutComponent } from "./anon-layout.component"; export interface AnonLayoutWrapperData { /** diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.mdx b/libs/components/src/anon-layout/anon-layout-wrapper.mdx similarity index 100% rename from libs/auth/src/angular/anon-layout/anon-layout-wrapper.mdx rename to libs/components/src/anon-layout/anon-layout-wrapper.mdx diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.stories.ts b/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts similarity index 88% rename from libs/auth/src/angular/anon-layout/anon-layout-wrapper.stories.ts rename to libs/components/src/anon-layout/anon-layout-wrapper.stories.ts index f106f9ee0dc..57fba034c7e 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.stories.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts @@ -14,24 +14,19 @@ import { EnvironmentService, Environment, } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -// 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 } from "@bitwarden/components"; -// FIXME: remove `/apps` import from `/libs` -// FIXME: remove `src` and fix import -// eslint-disable-next-line import/no-restricted-paths, no-restricted-imports -import { PreloadedEnglishI18nModule } from "../../../../../apps/web/src/app/core/tests"; -import { LockIcon } from "../icons"; -import { RegistrationCheckEmailIcon } from "../icons/registration-check-email.icon"; +import { ButtonModule } from "../button"; +import { LockIcon, RegistrationCheckEmailIcon } from "../icon/icons"; +import { I18nMockService } from "../utils"; import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "./anon-layout-wrapper.component"; import { DefaultAnonLayoutWrapperDataService } from "./default-anon-layout-wrapper-data.service"; export default { - title: "Auth/Anon Layout Wrapper", + title: "Component Library/Anon Layout Wrapper", component: AnonLayoutWrapperComponent, } as Meta; @@ -84,13 +79,21 @@ const decorators = (options: { getClientType: () => options.clientType || ClientType.Web, } as Partial, }, + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + setAStrongPassword: "Set a strong password", + appLogoLabel: "app logo label", + finishCreatingYourAccountBySettingAPassword: + "Finish creating your account by setting a password", + }); + }, + }, ], }), applicationConfig({ - providers: [ - importProvidersFrom(RouterModule.forRoot(options.routes)), - importProvidersFrom(PreloadedEnglishI18nModule), - ], + providers: [importProvidersFrom(RouterModule.forRoot(options.routes))], }), ]; }; @@ -102,18 +105,21 @@ type Story = StoryObj; @Component({ selector: "bit-default-primary-outlet-example-component", template: "

Primary Outlet Example:
your primary component goes here

", + standalone: false, }) export class DefaultPrimaryOutletExampleComponent {} @Component({ selector: "bit-default-secondary-outlet-example-component", template: "

Secondary Outlet Example:
your secondary component goes here

", + standalone: false, }) export class DefaultSecondaryOutletExampleComponent {} @Component({ selector: "bit-default-env-selector-outlet-example-component", template: "

Env Selector Outlet Example:
your env selector component goes here

", + standalone: false, }) export class DefaultEnvSelectorOutletExampleComponent {} @@ -188,6 +194,7 @@ const changedData: AnonLayoutWrapperData = { template: ` `, + standalone: false, }) export class DynamicContentExampleComponent { initialData = true; diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/components/src/anon-layout/anon-layout.component.html similarity index 100% rename from libs/auth/src/angular/anon-layout/anon-layout.component.html rename to libs/components/src/anon-layout/anon-layout.component.html diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts similarity index 82% rename from libs/auth/src/angular/anon-layout/anon-layout.component.ts rename to libs/components/src/anon-layout/anon-layout.component.ts index 1a20dd6fb52..4155a186384 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -9,16 +9,10 @@ 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"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { IconModule, Icon } from "../../../../components/src/icon"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { SharedModule } from "../../../../components/src/shared"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { TypographyModule } from "../../../../components/src/typography"; -import { BitwardenLogo, BitwardenShield } from "../icons"; +import { IconModule, Icon } from "../icon"; +import { BitwardenLogo, BitwardenShield } from "../icon/icons"; +import { SharedModule } from "../shared"; +import { TypographyModule } from "../typography"; @Component({ selector: "auth-anon-layout", diff --git a/libs/auth/src/angular/anon-layout/anon-layout.mdx b/libs/components/src/anon-layout/anon-layout.mdx similarity index 97% rename from libs/auth/src/angular/anon-layout/anon-layout.mdx rename to libs/components/src/anon-layout/anon-layout.mdx index 8aec3a06767..039a1aa5f28 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.mdx +++ b/libs/components/src/anon-layout/anon-layout.mdx @@ -6,8 +6,8 @@ import * as stories from "./anon-layout.stories"; # AnonLayout Component -The Auth-owned AnonLayoutComponent is to be used primarily for unauthenticated pages\*, where we -don't know who the user is. +The AnonLayoutComponent is to be used primarily for unauthenticated pages\*, where we don't know who +the user is. \*There will be a few exceptions to this—that is, AnonLayout will also be used for the Unlock and View Send pages. diff --git a/libs/auth/src/angular/anon-layout/anon-layout.stories.ts b/libs/components/src/anon-layout/anon-layout.stories.ts similarity index 96% rename from libs/auth/src/angular/anon-layout/anon-layout.stories.ts rename to libs/components/src/anon-layout/anon-layout.stories.ts index 34d561d5210..395703fc018 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.stories.ts +++ b/libs/components/src/anon-layout/anon-layout.stories.ts @@ -7,13 +7,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { ButtonModule } from "../../../../components/src/button"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { I18nMockService } from "../../../../components/src/utils/i18n-mock.service"; -import { LockIcon } from "../icons"; +import { ButtonModule } from "../button"; +import { LockIcon } from "../icon/icons"; +import { I18nMockService } from "../utils/i18n-mock.service"; import { AnonLayoutComponent } from "./anon-layout.component"; @@ -23,7 +19,7 @@ class MockPlatformUtilsService implements Partial { } export default { - title: "Auth/Anon Layout", + title: "Component Library/Anon Layout", component: AnonLayoutComponent, decorators: [ moduleMetadata({ @@ -38,6 +34,7 @@ export default { useFactory: () => { return new I18nMockService({ accessing: "Accessing", + appLogoLabel: "app logo label", }); }, }, diff --git a/libs/auth/src/angular/anon-layout/default-anon-layout-wrapper-data.service.ts b/libs/components/src/anon-layout/default-anon-layout-wrapper-data.service.ts similarity index 100% rename from libs/auth/src/angular/anon-layout/default-anon-layout-wrapper-data.service.ts rename to libs/components/src/anon-layout/default-anon-layout-wrapper-data.service.ts diff --git a/libs/components/src/anon-layout/index.ts b/libs/components/src/anon-layout/index.ts new file mode 100644 index 00000000000..764360e85dd --- /dev/null +++ b/libs/components/src/anon-layout/index.ts @@ -0,0 +1,4 @@ +export * from "./anon-layout-wrapper-data.service"; +export * from "./anon-layout-wrapper.component"; +export * from "./anon-layout.component"; +export * from "./default-anon-layout-wrapper-data.service"; diff --git a/libs/components/src/copy-click/copy-click.directive.spec.ts b/libs/components/src/copy-click/copy-click.directive.spec.ts index 38f8ccb43cb..321a18596e4 100644 --- a/libs/components/src/copy-click/copy-click.directive.spec.ts +++ b/libs/components/src/copy-click/copy-click.directive.spec.ts @@ -1,10 +1,11 @@ import { Component, ElementRef, ViewChild } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ToastService } from "../"; +import { ToastService, CopyClickListener, COPY_CLICK_LISTENER } from "../"; import { CopyClickDirective } from "./copy-click.directive"; @@ -34,10 +35,12 @@ describe("CopyClickDirective", () => { let fixture: ComponentFixture; const copyToClipboard = jest.fn(); const showToast = jest.fn(); + const copyClickListener = mock(); beforeEach(async () => { copyToClipboard.mockClear(); showToast.mockClear(); + copyClickListener.onCopy.mockClear(); await TestBed.configureTestingModule({ imports: [TestCopyClickComponent], @@ -55,6 +58,7 @@ describe("CopyClickDirective", () => { }, { provide: PlatformUtilsService, useValue: { copyToClipboard } }, { provide: ToastService, useValue: { showToast } }, + { provide: COPY_CLICK_LISTENER, useValue: copyClickListener }, ], }).compileComponents(); @@ -92,7 +96,6 @@ describe("CopyClickDirective", () => { successToastButton.click(); expect(showToast).toHaveBeenCalledWith({ message: "copySuccessful", - title: null, variant: "success", }); }); @@ -103,7 +106,6 @@ describe("CopyClickDirective", () => { infoToastButton.click(); expect(showToast).toHaveBeenCalledWith({ message: "copySuccessful", - title: null, variant: "info", }); }); @@ -115,8 +117,15 @@ describe("CopyClickDirective", () => { expect(showToast).toHaveBeenCalledWith({ message: "valueCopied Content", - title: null, variant: "success", }); }); + + it("should call copyClickListener.onCopy when value is copied", () => { + const successToastButton = fixture.componentInstance.successToastButton.nativeElement; + + successToastButton.click(); + + expect(copyClickListener.onCopy).toHaveBeenCalledWith("success toast shown"); + }); }); diff --git a/libs/components/src/copy-click/copy-click.directive.ts b/libs/components/src/copy-click/copy-click.directive.ts index 1dfaf4387dc..514a55a0242 100644 --- a/libs/components/src/copy-click/copy-click.directive.ts +++ b/libs/components/src/copy-click/copy-click.directive.ts @@ -1,12 +1,19 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive, HostListener, Input } from "@angular/core"; +import { Directive, HostListener, Input, InjectionToken, Inject, Optional } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ToastService, ToastVariant } from "../"; +/** + * Listener that can be provided to receive copy events to allow for customized behavior. + */ +export interface CopyClickListener { + onCopy(value: string): void; +} + +export const COPY_CLICK_LISTENER = new InjectionToken("CopyClickListener"); + @Directive({ selector: "[appCopyClick]", }) @@ -18,6 +25,7 @@ export class CopyClickDirective { private platformUtilsService: PlatformUtilsService, private toastService: ToastService, private i18nService: I18nService, + @Optional() @Inject(COPY_CLICK_LISTENER) private copyListener?: CopyClickListener, ) {} @Input("appCopyClick") valueToCopy = ""; @@ -26,7 +34,7 @@ export class CopyClickDirective { * When set, the toast displayed will show ` copied` * instead of the default messaging. */ - @Input() valueLabel: string; + @Input() valueLabel?: string; /** * When set without a value, a success toast will be shown when the value is copied @@ -54,6 +62,10 @@ export class CopyClickDirective { @HostListener("click") onClick() { this.platformUtilsService.copyToClipboard(this.valueToCopy); + if (this.copyListener) { + this.copyListener.onCopy(this.valueToCopy); + } + if (this._showToast) { const message = this.valueLabel ? this.i18nService.t("valueCopied", this.valueLabel) @@ -61,7 +73,6 @@ export class CopyClickDirective { this.toastService.showToast({ variant: this.toastVariant, - title: null, message, }); } diff --git a/libs/components/src/dialog/dialog.service.stories.ts b/libs/components/src/dialog/dialog.service.stories.ts index a9fe92ea4bf..7e2d8c62bb6 100644 --- a/libs/components/src/dialog/dialog.service.stories.ts +++ b/libs/components/src/dialog/dialog.service.stories.ts @@ -1,11 +1,17 @@ import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; -import { provideAnimations } from "@angular/platform-browser/animations"; -import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { NoopAnimationsModule, provideAnimations } from "@angular/platform-browser/animations"; +import { RouterTestingModule } from "@angular/router/testing"; +import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { getAllByRole, userEvent } from "@storybook/test"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonModule } from "../button"; +import { IconButtonModule } from "../icon-button"; +import { LayoutComponent } from "../layout"; +import { SharedModule } from "../shared"; +import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { I18nMockService } from "../utils/i18n-mock.service"; import { DialogModule } from "./dialog.module"; @@ -16,7 +22,12 @@ interface Animal { } @Component({ - template: ``, + template: ` + + + + + `, imports: [ButtonModule], }) class StoryDialogComponent { @@ -29,6 +40,14 @@ class StoryDialogComponent { }, }); } + + openDrawer() { + this.dialogService.openDrawer(StoryDialogContentComponent, { + data: { + animal: "panda", + }, + }); + } } @Component({ @@ -64,7 +83,21 @@ export default { title: "Component Library/Dialogs/Service", component: StoryDialogComponent, decorators: [ + positionFixedWrapperDecorator(), moduleMetadata({ + declarations: [StoryDialogContentComponent], + imports: [ + SharedModule, + ButtonModule, + NoopAnimationsModule, + DialogModule, + IconButtonModule, + RouterTestingModule, + LayoutComponent, + ], + providers: [DialogService], + }), + applicationConfig({ providers: [ provideAnimations(), DialogService, @@ -73,7 +106,13 @@ export default { useFactory: () => { return new I18nMockService({ close: "Close", - loading: "Loading", + search: "Search", + skipToContent: "Skip to content", + submenu: "submenu", + toggleCollapse: "toggle collapse", + toggleSideNavigation: "Toggle side navigation", + yes: "Yes", + no: "No", }); }, }, @@ -90,4 +129,21 @@ export default { type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + play: async (context) => { + const canvas = context.canvasElement; + + const button = getAllByRole(canvas, "button")[0]; + await userEvent.click(button); + }, +}; + +/** Drawers must be a descendant of `bit-layout`. */ +export const Drawer: Story = { + play: async (context) => { + const canvas = context.canvasElement; + + const button = getAllByRole(canvas, "button")[1]; + await userEvent.click(button); + }, +}; diff --git a/libs/components/src/dialog/dialog.service.ts b/libs/components/src/dialog/dialog.service.ts index 83aaaff470e..409bf0a5b55 100644 --- a/libs/components/src/dialog/dialog.service.ts +++ b/libs/components/src/dialog/dialog.service.ts @@ -1,31 +1,25 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { - DEFAULT_DIALOG_CONFIG, - Dialog, - DialogConfig, - DialogRef, - DIALOG_SCROLL_STRATEGY, + Dialog as CdkDialog, + DialogConfig as CdkDialogConfig, + DialogRef as CdkDialogRefBase, + DIALOG_DATA, + DialogCloseOptions, } from "@angular/cdk/dialog"; -import { ComponentType, Overlay, OverlayContainer, ScrollStrategy } from "@angular/cdk/overlay"; -import { - Inject, - Injectable, - Injector, - OnDestroy, - Optional, - SkipSelf, - TemplateRef, -} from "@angular/core"; +import { ComponentType, ScrollStrategy } from "@angular/cdk/overlay"; +import { ComponentPortal, Portal } from "@angular/cdk/portal"; +import { Injectable, Injector, TemplateRef, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; -import { filter, firstValueFrom, Subject, switchMap, takeUntil } from "rxjs"; +import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DrawerService } from "../drawer/drawer.service"; + import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component"; -import { SimpleDialogOptions, Translation } from "./simple-dialog/types"; +import { SimpleDialogOptions } from "./simple-dialog/types"; /** * The default `BlockScrollStrategy` does not work well with virtual scrolling. @@ -48,61 +42,163 @@ class CustomBlockScrollStrategy implements ScrollStrategy { detach() {} } +export abstract class DialogRef + implements Pick, "close" | "closed" | "disableClose" | "componentInstance"> +{ + abstract readonly isDrawer?: boolean; + + // --- From CdkDialogRef --- + abstract close(result?: R, options?: DialogCloseOptions): void; + abstract readonly closed: Observable; + abstract disableClose: boolean | undefined; + /** + * @deprecated + * Does not work with drawer dialogs. + **/ + abstract componentInstance: C | null; +} + +export type DialogConfig = Pick< + CdkDialogConfig, + "data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" +>; + +class DrawerDialogRef implements DialogRef { + readonly isDrawer = true; + + private _closed = new Subject(); + closed = this._closed.asObservable(); + disableClose = false; + + /** The portal containing the drawer */ + portal?: Portal; + + constructor(private drawerService: DrawerService) {} + + close(result?: R, _options?: DialogCloseOptions): void { + if (this.disableClose) { + return; + } + this.drawerService.close(this.portal!); + this._closed.next(result); + this._closed.complete(); + } + + componentInstance: C | null = null; +} + +/** + * DialogRef that delegates functionality to the CDK implementation + **/ +export class CdkDialogRef implements DialogRef { + readonly isDrawer = false; + + /** This is not available until after construction, @see DialogService.open. */ + cdkDialogRefBase!: CdkDialogRefBase; + + // --- Delegated to CdkDialogRefBase --- + + close(result?: R, options?: DialogCloseOptions): void { + this.cdkDialogRefBase.close(result, options); + } + + get closed(): Observable { + return this.cdkDialogRefBase.closed; + } + + get disableClose(): boolean | undefined { + return this.cdkDialogRefBase.disableClose; + } + set disableClose(value: boolean | undefined) { + this.cdkDialogRefBase.disableClose = value; + } + + // Delegate the `componentInstance` property to the CDK DialogRef + get componentInstance(): C | null { + return this.cdkDialogRefBase.componentInstance; + } +} + @Injectable() -export class DialogService extends Dialog implements OnDestroy { - private _destroy$ = new Subject(); +export class DialogService { + private dialog = inject(CdkDialog); + private drawerService = inject(DrawerService); + private injector = inject(Injector); + private router = inject(Router, { optional: true }); + private authService = inject(AuthService, { optional: true }); + private i18nService = inject(I18nService); private backDropClasses = ["tw-fixed", "tw-bg-black", "tw-bg-opacity-30", "tw-inset-0"]; - private defaultScrollStrategy = new CustomBlockScrollStrategy(); + private activeDrawer: DrawerDialogRef | null = null; - constructor( - /** Parent class constructor */ - _overlay: Overlay, - _injector: Injector, - @Optional() @Inject(DEFAULT_DIALOG_CONFIG) _defaultOptions: DialogConfig, - @Optional() @SkipSelf() _parentDialog: Dialog, - _overlayContainer: OverlayContainer, - @Inject(DIALOG_SCROLL_STRATEGY) scrollStrategy: any, - - /** Not in parent class */ - @Optional() router: Router, - @Optional() authService: AuthService, - - protected i18nService: I18nService, - ) { - super(_overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, scrollStrategy); - + constructor() { + /** + * TODO: This logic should exist outside of `libs/components`. + * @see https://bitwarden.atlassian.net/browse/CL-657 + **/ /** Close all open dialogs if the vault locks */ - if (router && authService) { - router.events + if (this.router && this.authService) { + this.router.events .pipe( filter((event) => event instanceof NavigationEnd), - switchMap(() => authService.getAuthStatus()), + switchMap(() => this.authService!.getAuthStatus()), filter((v) => v !== AuthenticationStatus.Unlocked), - takeUntil(this._destroy$), + takeUntilDestroyed(), ) .subscribe(() => this.closeAll()); } } - override ngOnDestroy(): void { - this._destroy$.next(); - this._destroy$.complete(); - super.ngOnDestroy(); - } - - override open( + open( componentOrTemplateRef: ComponentType | TemplateRef, config?: DialogConfig>, ): DialogRef { - config = { + /** + * This is a bit circular in nature: + * We need the DialogRef instance for the DI injector that is passed *to* `Dialog.open`, + * but we get the base CDK DialogRef instance *from* `Dialog.open`. + * + * To break the circle, we define CDKDialogRef as a wrapper for the CDKDialogRefBase. + * This allows us to create the class instance and provide the base instance later, almost like "deferred inheritance". + **/ + const ref = new CdkDialogRef(); + const injector = this.createInjector({ + data: config?.data, + dialogRef: ref, + }); + + // Merge the custom config with the default config + const _config = { backdropClass: this.backDropClasses, scrollStrategy: this.defaultScrollStrategy, + injector, ...config, }; - return super.open(componentOrTemplateRef, config); + ref.cdkDialogRefBase = this.dialog.open(componentOrTemplateRef, _config); + return ref; + } + + /** Opens a dialog in the side drawer */ + openDrawer( + component: ComponentType, + config?: DialogConfig>, + ): DialogRef { + this.activeDrawer?.close(); + /** + * This is also circular. When creating the DrawerDialogRef, we do not yet have a portal instance to provide to the injector. + * Similar to `this.open`, we get around this with mutability. + */ + this.activeDrawer = new DrawerDialogRef(this.drawerService); + const portal = new ComponentPortal( + component, + null, + this.createInjector({ data: config?.data, dialogRef: this.activeDrawer }), + ); + this.activeDrawer.portal = portal; + this.drawerService.open(portal); + return this.activeDrawer; } /** @@ -113,8 +209,7 @@ export class DialogService extends Dialog implements OnDestroy { */ async openSimpleDialog(simpleDialogOptions: SimpleDialogOptions): Promise { const dialogRef = this.openSimpleDialogRef(simpleDialogOptions); - - return firstValueFrom(dialogRef.closed); + return firstValueFrom(dialogRef.closed.pipe(map((v: boolean | undefined) => !!v))); } /** @@ -134,20 +229,29 @@ export class DialogService extends Dialog implements OnDestroy { }); } - protected translate(translation: string | Translation, defaultKey?: string): string { - if (translation == null && defaultKey == null) { - return null; - } + /** Close all open dialogs */ + closeAll(): void { + return this.dialog.closeAll(); + } - if (translation == null) { - return this.i18nService.t(defaultKey); - } - - // Translation interface use implies we must localize. - if (typeof translation === "object") { - return this.i18nService.t(translation.key, ...(translation.placeholders ?? [])); - } - - return translation; + /** The injector that is passed to the opened dialog */ + private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector { + return Injector.create({ + providers: [ + { + provide: DIALOG_DATA, + useValue: opts.data, + }, + { + provide: DialogRef, + useValue: opts.dialogRef, + }, + { + provide: CdkDialogRefBase, + useValue: opts.dialogRef, + }, + ], + parent: this.injector, + }); } } diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 01f05985127..eaf7fc2beec 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -1,12 +1,22 @@ +@let isDrawer = dialogRef?.isDrawer;
+ @let showHeaderBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().top;
-

} -

+
+ @let showFooterBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().bottom;
diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts index de521b62909..f3daa218cdb 100644 --- a/libs/components/src/dialog/dialog/dialog.component.ts +++ b/libs/components/src/dialog/dialog/dialog.component.ts @@ -1,14 +1,18 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { CdkTrapFocus } from "@angular/cdk/a11y"; import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { CdkScrollable } from "@angular/cdk/scrolling"; import { CommonModule } from "@angular/common"; -import { Component, HostBinding, Input } from "@angular/core"; +import { Component, HostBinding, Input, inject, viewChild } from "@angular/core"; import { I18nPipe } from "@bitwarden/ui-common"; import { BitIconButtonComponent } from "../../icon-button/icon-button.component"; import { TypographyDirective } from "../../typography/typography.directive"; +import { hasScrolledFrom } from "../../utils/has-scrolled-from"; import { fadeIn } from "../animations"; +import { DialogRef } from "../dialog.service"; import { DialogCloseDirective } from "../directives/dialog-close.directive"; import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive"; @@ -16,6 +20,9 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai selector: "bit-dialog", templateUrl: "./dialog.component.html", animations: [fadeIn], + host: { + "(keydown.esc)": "handleEsc($event)", + }, imports: [ CommonModule, DialogTitleContainerDirective, @@ -23,9 +30,15 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai BitIconButtonComponent, DialogCloseDirective, I18nPipe, + CdkTrapFocus, + CdkScrollable, ], }) export class DialogComponent { + protected dialogRef = inject(DialogRef, { optional: true }); + private scrollableBody = viewChild.required(CdkScrollable); + protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody); + /** Background color */ @Input() background: "default" | "alt" = "default"; @@ -63,21 +76,31 @@ export class DialogComponent { @HostBinding("class") get classes() { // `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header - return ["tw-flex", "tw-flex-col", "tw-w-screen", "tw-p-4", "tw-max-h-[90vh]"].concat( - this.width, - ); + return ["tw-flex", "tw-flex-col", "tw-w-screen"] + .concat( + this.width, + this.dialogRef?.isDrawer + ? ["tw-min-h-screen", "md:tw-w-[23rem]"] + : ["tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"], + ) + .flat(); + } + + handleEsc(event: Event) { + this.dialogRef?.close(); + event.stopPropagation(); } get width() { switch (this.dialogSize) { case "small": { - return "tw-max-w-sm"; + return "md:tw-max-w-sm"; } case "large": { - return "tw-max-w-3xl"; + return "md:tw-max-w-3xl"; } default: { - return "tw-max-w-xl"; + return "md:tw-max-w-xl"; } } } diff --git a/libs/components/src/dialog/dialogs.mdx b/libs/components/src/dialog/dialogs.mdx index 63df0bfc131..3f44f31a5eb 100644 --- a/libs/components/src/dialog/dialogs.mdx +++ b/libs/components/src/dialog/dialogs.mdx @@ -22,6 +22,9 @@ For alerts or simple confirmation actions, like speedbumps, use the Dialogs's should be used sparingly as they do call extra attention to themselves and can be interruptive if overused. +For non-blocking, supplementary content, open dialogs as a +[Drawer](?path=/story/component-library-dialogs-service--drawer) (requires `bit-layout`). + ## Placement Dialogs should be centered vertically and horizontally on screen. Dialogs height should expand to diff --git a/libs/components/src/dialog/index.ts b/libs/components/src/dialog/index.ts index 0ab9a5d9e67..fb4c2721b81 100644 --- a/libs/components/src/dialog/index.ts +++ b/libs/components/src/dialog/index.ts @@ -1,4 +1,4 @@ export * from "./dialog.module"; export * from "./simple-dialog/types"; export * from "./dialog.service"; -export { DialogConfig, DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +export { DIALOG_DATA } from "@angular/cdk/dialog"; diff --git a/libs/components/src/drawer/drawer-body.component.ts b/libs/components/src/drawer/drawer-body.component.ts index d491425f68a..9b5d3148d9b 100644 --- a/libs/components/src/drawer/drawer-body.component.ts +++ b/libs/components/src/drawer/drawer-body.component.ts @@ -1,7 +1,7 @@ import { CdkScrollable } from "@angular/cdk/scrolling"; -import { ChangeDetectionStrategy, Component, Signal, inject } from "@angular/core"; -import { toSignal } from "@angular/core/rxjs-interop"; -import { map } from "rxjs"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +import { hasScrolledFrom } from "../utils/has-scrolled-from"; /** * Body container for `bit-drawer` @@ -13,7 +13,7 @@ import { map } from "rxjs"; host: { class: "tw-p-4 tw-pt-0 tw-block tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200", - "[class.tw-border-t-secondary-300]": "isScrolled()", + "[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top", }, hostDirectives: [ { @@ -23,13 +23,5 @@ import { map } from "rxjs"; template: ` `, }) export class DrawerBodyComponent { - private scrollable = inject(CdkScrollable); - - /** TODO: share this utility with browser popup header? */ - protected isScrolled: Signal = toSignal( - this.scrollable - .elementScrolled() - .pipe(map(() => this.scrollable.measureScrollOffset("top") > 0)), - { initialValue: false }, - ); + protected hasScrolledFrom = hasScrolledFrom(); } diff --git a/libs/components/src/drawer/drawer.component.ts b/libs/components/src/drawer/drawer.component.ts index 387bd63c918..7a3c764b16f 100644 --- a/libs/components/src/drawer/drawer.component.ts +++ b/libs/components/src/drawer/drawer.component.ts @@ -10,7 +10,7 @@ import { viewChild, } from "@angular/core"; -import { DrawerHostDirective } from "./drawer-host.directive"; +import { DrawerService } from "./drawer.service"; /** * A drawer is a panel of supplementary content that is adjacent to the page's main content. @@ -24,7 +24,7 @@ import { DrawerHostDirective } from "./drawer-host.directive"; templateUrl: "drawer.component.html", }) export class DrawerComponent { - private drawerHost = inject(DrawerHostDirective); + private drawerHost = inject(DrawerService); private portal = viewChild.required(CdkPortal); /** diff --git a/libs/components/src/drawer/drawer.mdx b/libs/components/src/drawer/drawer.mdx index 57d618cfe95..bc99fa290d6 100644 --- a/libs/components/src/drawer/drawer.mdx +++ b/libs/components/src/drawer/drawer.mdx @@ -12,6 +12,8 @@ import { DrawerComponent } from "@bitwarden/components"; # Drawer +**Note: `bit-drawer` is deprecated. Use `bit-dialog` and `DialogService.openDrawer(...)` instead.** + A drawer is a panel of supplementary content that is adjacent to the page's main content. diff --git a/libs/components/src/drawer/drawer.service.ts b/libs/components/src/drawer/drawer.service.ts new file mode 100644 index 00000000000..dd8575efee8 --- /dev/null +++ b/libs/components/src/drawer/drawer.service.ts @@ -0,0 +1,20 @@ +import { Portal } from "@angular/cdk/portal"; +import { Injectable, signal } from "@angular/core"; + +@Injectable({ providedIn: "root" }) +export class DrawerService { + private _portal = signal | undefined>(undefined); + + /** The portal to display */ + portal = this._portal.asReadonly(); + + open(portal: Portal) { + this._portal.set(portal); + } + + close(portal: Portal) { + if (portal === this.portal()) { + this._portal.set(undefined); + } + } +} diff --git a/libs/auth/src/angular/icons/bitwarden-logo.icon.ts b/libs/components/src/icon/icons/bitwarden-logo.icon.ts similarity index 96% rename from libs/auth/src/angular/icons/bitwarden-logo.icon.ts rename to libs/components/src/icon/icons/bitwarden-logo.icon.ts index 2df07c45ff9..27b8ece164d 100644 --- a/libs/auth/src/angular/icons/bitwarden-logo.icon.ts +++ b/libs/components/src/icon/icons/bitwarden-logo.icon.ts @@ -1,6 +1,4 @@ -// 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 { svgIcon } from "@bitwarden/components"; +import { svgIcon } from "../icon"; export const BitwardenLogo = svgIcon` diff --git a/libs/auth/src/angular/icons/bitwarden-shield.icon.ts b/libs/components/src/icon/icons/bitwarden-shield.icon.ts similarity index 85% rename from libs/auth/src/angular/icons/bitwarden-shield.icon.ts rename to libs/components/src/icon/icons/bitwarden-shield.icon.ts index f40dc97e5ee..7abeaf40e3c 100644 --- a/libs/auth/src/angular/icons/bitwarden-shield.icon.ts +++ b/libs/components/src/icon/icons/bitwarden-shield.icon.ts @@ -1,6 +1,4 @@ -// 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 { svgIcon } from "@bitwarden/components"; +import { svgIcon } from "../icon"; export const BitwardenShield = svgIcon` diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-bitwarden-logo.icon.ts b/libs/components/src/icon/icons/extension-bitwarden-logo.icon.ts similarity index 99% rename from apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-bitwarden-logo.icon.ts rename to libs/components/src/icon/icons/extension-bitwarden-logo.icon.ts index 1de4bd37239..a8a07d5d1ef 100644 --- a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-bitwarden-logo.icon.ts +++ b/libs/components/src/icon/icons/extension-bitwarden-logo.icon.ts @@ -1,4 +1,4 @@ -import { svgIcon } from "@bitwarden/components"; +import { svgIcon } from "../icon"; export const ExtensionBitwardenLogo = svgIcon` diff --git a/libs/auth/src/angular/icons/registration-check-email.icon.ts b/libs/components/src/icon/icons/registration-check-email.icon.ts similarity index 90% rename from libs/auth/src/angular/icons/registration-check-email.icon.ts rename to libs/components/src/icon/icons/registration-check-email.icon.ts index d32964d8cb1..f0e881e5b2d 100644 --- a/libs/auth/src/angular/icons/registration-check-email.icon.ts +++ b/libs/components/src/icon/icons/registration-check-email.icon.ts @@ -1,6 +1,4 @@ -// 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 { svgIcon } from "@bitwarden/components"; +import { svgIcon } from "../icon"; export const RegistrationCheckEmailIcon = svgIcon` diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 284dc639746..d231048563c 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -1,5 +1,6 @@ export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction"; export * from "./a11y"; +export * from "./anon-layout"; export * from "./async-actions"; export * from "./avatar"; export * from "./badge-list"; diff --git a/libs/components/src/layout/index.ts b/libs/components/src/layout/index.ts index 6994a4f639f..a257a4dde85 100644 --- a/libs/components/src/layout/index.ts +++ b/libs/components/src/layout/index.ts @@ -1 +1,2 @@ export * from "./layout.component"; +export * from "./scroll-layout.directive"; diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index f4b0a09db1e..f31d2901b00 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -1,43 +1,54 @@ - +@let mainContentId = "main-content";
- -
- +
+ + +
+ - - @if ( - { - open: sideNavService.open$ | async, - }; - as data - ) { -
- @if (data.open) { -
- } -
- } -
- + + @if ( + { + open: sideNavService.open$ | async, + }; + as data + ) { +
+ @if (data.open) { +
+ } +
+ } +
+
+
+ +
diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts index 99e31f2b64e..54b0341603c 100644 --- a/libs/components/src/layout/layout.component.ts +++ b/libs/components/src/layout/layout.component.ts @@ -1,26 +1,61 @@ +import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y"; import { PortalModule } from "@angular/cdk/portal"; import { CommonModule } from "@angular/common"; -import { Component, inject } from "@angular/core"; +import { Component, ElementRef, inject, viewChild } from "@angular/core"; import { RouterModule } from "@angular/router"; import { DrawerHostDirective } from "../drawer/drawer-host.directive"; +import { DrawerService } from "../drawer/drawer.service"; import { LinkModule } from "../link"; import { SideNavService } from "../navigation/side-nav.service"; import { SharedModule } from "../shared"; +import { ScrollLayoutHostDirective } from "./scroll-layout.directive"; + @Component({ selector: "bit-layout", templateUrl: "layout.component.html", - imports: [CommonModule, SharedModule, LinkModule, RouterModule, PortalModule], + imports: [ + CommonModule, + SharedModule, + LinkModule, + RouterModule, + PortalModule, + A11yModule, + CdkTrapFocus, + ScrollLayoutHostDirective, + ], + host: { + "(document:keydown.tab)": "handleKeydown($event)", + }, hostDirectives: [DrawerHostDirective], }) export class LayoutComponent { - protected mainContentId = "main-content"; - protected sideNavService = inject(SideNavService); - protected drawerPortal = inject(DrawerHostDirective).portal; + protected drawerPortal = inject(DrawerService).portal; - focusMainContent() { - document.getElementById(this.mainContentId)?.focus(); + private mainContent = viewChild.required>("main"); + protected focusMainContent() { + this.mainContent().nativeElement.focus(); + } + + /** + * Angular CDK's focus trap utility is silly and will not respect focus order. + * This is a workaround to explicitly focus the skip link when tab is first pressed, if no other item already has focus. + * + * @see https://github.com/angular/components/issues/10247#issuecomment-384060265 + **/ + private skipLink = viewChild.required>("skipLink"); + handleKeydown(ev: KeyboardEvent) { + if (isNothingFocused()) { + ev.preventDefault(); + this.skipLink().nativeElement.focus(); + } } } + +const isNothingFocused = (): boolean => { + return [document.documentElement, document.body, null].includes( + document.activeElement as HTMLElement, + ); +}; diff --git a/libs/components/src/layout/scroll-layout.directive.ts b/libs/components/src/layout/scroll-layout.directive.ts new file mode 100644 index 00000000000..cb2c2a4e431 --- /dev/null +++ b/libs/components/src/layout/scroll-layout.directive.ts @@ -0,0 +1,98 @@ +import { CdkVirtualScrollable, VIRTUAL_SCROLLABLE } from "@angular/cdk/scrolling"; +import { + Directive, + ElementRef, + Injectable, + OnDestroy, + OnInit, + effect, + inject, + signal, +} from "@angular/core"; +import { toObservable } from "@angular/core/rxjs-interop"; +import { filter, fromEvent, Observable, switchMap } from "rxjs"; + +/** + * A service is needed because we can't inject a directive defined in the template of a parent component. The parent's template is initialized after projected content. + **/ +@Injectable({ providedIn: "root" }) +export class ScrollLayoutService { + scrollableRef = signal | null>(null); + scrollableRef$ = toObservable(this.scrollableRef); +} + +/** + * Marks the primary scrollable area of a layout component. + * + * Stores the element reference in a global service so it can be referenced by `ScrollLayoutDirective` even when it isn't a direct child of this directive. + **/ +@Directive({ + selector: "[bitScrollLayoutHost]", + standalone: true, + host: { + class: "cdk-virtual-scrollable", + }, +}) +export class ScrollLayoutHostDirective implements OnDestroy { + private ref = inject(ElementRef); + private service = inject(ScrollLayoutService); + + constructor() { + this.service.scrollableRef.set(this.ref as ElementRef); + } + + ngOnDestroy(): void { + this.service.scrollableRef.set(null); + } +} + +/** + * Sets the scroll viewport to the element marked with `ScrollLayoutHostDirective`. + * + * `ScrollLayoutHostDirective` is set on the primary scrollable area of a layout component (`bit-layout`, `popup-page`, etc). + * + * @see "Virtual Scrolling" in Storybook. + */ +@Directive({ + selector: "[bitScrollLayout]", + standalone: true, + providers: [{ provide: VIRTUAL_SCROLLABLE, useExisting: ScrollLayoutDirective }], +}) +export class ScrollLayoutDirective extends CdkVirtualScrollable implements OnInit { + private service = inject(ScrollLayoutService); + + constructor() { + super(); + + effect(() => { + const scrollableRef = this.service.scrollableRef(); + if (!scrollableRef) { + // eslint-disable-next-line no-console + console.error("ScrollLayoutDirective can't find scroll host"); + return; + } + + this.elementRef = scrollableRef; + }); + } + + override elementScrolled(): Observable { + return this.service.scrollableRef$.pipe( + filter((ref) => ref !== null), + switchMap((ref) => fromEvent(ref.nativeElement, "scroll")), + ); + } + + override getElementRef(): ElementRef { + return this.service.scrollableRef()!; + } + + override measureBoundingClientRectWithScrollOffset( + from: "left" | "top" | "right" | "bottom", + ): number { + return ( + this.service.scrollableRef()!.nativeElement.getBoundingClientRect()[from] - + this.measureScrollOffset(from) + ); + } +} diff --git a/libs/components/src/stories/kitchen-sink/components/dialog-virtual-scroll-block.component.ts b/libs/components/src/stories/kitchen-sink/components/dialog-virtual-scroll-block.component.ts index 4a8c2b06953..904b9e11c3a 100644 --- a/libs/components/src/stories/kitchen-sink/components/dialog-virtual-scroll-block.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/dialog-virtual-scroll-block.component.ts @@ -3,14 +3,23 @@ import { Component, OnInit } from "@angular/core"; import { DialogModule, DialogService } from "../../../dialog"; import { IconButtonModule } from "../../../icon-button"; +import { ScrollLayoutDirective } from "../../../layout"; import { SectionComponent } from "../../../section"; import { TableDataSource, TableModule } from "../../../table"; @Component({ selector: "dialog-virtual-scroll-block", - imports: [DialogModule, IconButtonModule, SectionComponent, TableModule, ScrollingModule], + standalone: true, + imports: [ + DialogModule, + IconButtonModule, + SectionComponent, + TableModule, + ScrollingModule, + ScrollLayoutDirective, + ], template: /*html*/ ` - + diff --git a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts index 7fc222bd036..767659de3cb 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts @@ -11,8 +11,69 @@ import { KitchenSinkToggleList } from "./kitchen-sink-toggle-list.component"; @Component({ imports: [KitchenSinkSharedModule], template: ` - - Dialog body text goes here. + + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+ + What did foo say to bar? + + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+
@@ -88,72 +149,6 @@ class KitchenSinkDialog {
- - - - -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

- - What did foo say to bar? - - -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-
-
`, }) export class KitchenSinkMainComponent { @@ -166,7 +161,7 @@ export class KitchenSinkMainComponent { } openDrawer() { - this.drawerOpen.set(true); + this.dialogService.openDrawer(KitchenSinkDialog); } navItems = [ diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts index f57a9de4e68..d318e1b5f0e 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts @@ -14,7 +14,6 @@ import { import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogService } from "../../dialog"; import { LayoutComponent } from "../../layout"; import { I18nMockService } from "../../utils/i18n-mock.service"; import { positionFixedWrapperDecorator } from "../storybook-decorators"; @@ -39,8 +38,20 @@ export default { KitchenSinkTable, KitchenSinkToggleList, ], + }), + applicationConfig({ providers: [ - DialogService, + provideNoopAnimations(), + importProvidersFrom( + RouterModule.forRoot( + [ + { path: "", redirectTo: "bitwarden", pathMatch: "full" }, + { path: "bitwarden", component: KitchenSinkMainComponent }, + { path: "virtual-scroll", component: DialogVirtualScrollBlockComponent }, + ], + { useHash: true }, + ), + ), { provide: I18nService, useFactory: () => { @@ -58,21 +69,6 @@ export default { }, ], }), - applicationConfig({ - providers: [ - provideNoopAnimations(), - importProvidersFrom( - RouterModule.forRoot( - [ - { path: "", redirectTo: "bitwarden", pathMatch: "full" }, - { path: "bitwarden", component: KitchenSinkMainComponent }, - { path: "virtual-scroll", component: DialogVirtualScrollBlockComponent }, - ], - { useHash: true }, - ), - ), - ], - }), ], } as Meta; diff --git a/libs/components/src/stories/virtual-scrolling.mdx b/libs/components/src/stories/virtual-scrolling.mdx new file mode 100644 index 00000000000..94a86090dce --- /dev/null +++ b/libs/components/src/stories/virtual-scrolling.mdx @@ -0,0 +1,60 @@ +import { Meta } from "@storybook/addon-docs"; + + + +# Virtual Scrolling + +Virtual scrolling is a technique that improves the rendering performance of very large lists by only +rendering whatever is currently visible within the viewport. We build on top of +[Angular CDK's `ScrollingModule`](https://material.angular.dev/cdk/scrolling/overview). + +## Scrolling the entire layout + +Often, a design calls for the scroll container to envelop the entire page. To support this, +AngularCDK provides a `scrollWindow` directive that sets the window to be virtual scroll viewport. +We export a similar directive, `bitScrollLayout`, that integrates with `bit-layout` and `popup-page` +and should be used instead of `scrollWindow`. + +```html + + + + +``` + +### Known footgun + +Due to the initialization order of Angular components and their templates, `bitScrollLayout` will +error if it is used _in the same template_ as the layout component: + +```html + + + + + +``` + +In this particular composition, the child content gets constructed before the template of +`bit-layout` and thus has no scroll container to reference. Workarounds include: + +1. Wrap the child in another component. (This tends to happen by default when the layout is + integrated with a `router-outlet`.) + +```html + + + +``` + +2. Use a `defer` block. + +```html + + @defer (on immediate) { + + +
+ } + +``` diff --git a/libs/components/src/table/table-scroll.component.html b/libs/components/src/table/table-scroll.component.html index 8f2c88ba3ad..523912cd7ac 100644 --- a/libs/components/src/table/table-scroll.component.html +++ b/libs/components/src/table/table-scroll.component.html @@ -1,5 +1,5 @@ diff --git a/libs/components/src/table/table-scroll.component.ts b/libs/components/src/table/table-scroll.component.ts index b463b12f6ce..193d790e416 100644 --- a/libs/components/src/table/table-scroll.component.ts +++ b/libs/components/src/table/table-scroll.component.ts @@ -4,7 +4,6 @@ import { CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf, - CdkVirtualScrollableWindow, } from "@angular/cdk/scrolling"; import { CommonModule } from "@angular/common"; import { @@ -21,6 +20,8 @@ import { TrackByFunction, } from "@angular/core"; +import { ScrollLayoutDirective } from "../layout"; + import { RowDirective } from "./row.directive"; import { TableComponent } from "./table.component"; @@ -52,10 +53,10 @@ export class BitRowDef { imports: [ CommonModule, CdkVirtualScrollViewport, - CdkVirtualScrollableWindow, CdkFixedSizeVirtualScroll, CdkVirtualForOf, RowDirective, + ScrollLayoutDirective, ], }) export class TableScrollComponent diff --git a/libs/components/src/table/table.mdx b/libs/components/src/table/table.mdx index 8d784190ed9..59bf5b773a3 100644 --- a/libs/components/src/table/table.mdx +++ b/libs/components/src/table/table.mdx @@ -142,7 +142,7 @@ dataSource.filter = (data) => data.orgType === "family"; Rudimentary string filtering is supported out of the box with `TableDataSource.simpleStringFilter`. It works by converting each entry into a string of it's properties. The provided string is then -compared against the filter value using a simple `indexOf` check. For convienence, you can also just +compared against the filter value using a simple `indexOf` check. For convenience, you can also just pass a string directly. ```ts @@ -153,7 +153,7 @@ dataSource.filter = "search value"; ### Virtual Scrolling -It's heavily adviced to use virtual scrolling if you expect the table to have any significant amount +It's heavily advised to use virtual scrolling if you expect the table to have any significant amount of data. This is done by using the `bit-table-scroll` component instead of the `bit-table` component. This component behaves slightly different from the `bit-table` component. Instead of using the `*ngFor` directive to render the rows, you provide a `bitRowDef` template that will be @@ -178,6 +178,14 @@ height and align vertically. ``` +#### Deprecated approach + +Before `bit-table-scroll` was introduced, virtual scroll in tables was implemented manually via +constructs from Angular CDK. This included wrapping the table with a `cdk-virtual-scroll-viewport` +and targeting with `bit-layout`'s scroll container with the `bitScrollLayout` directive. + +This pattern is deprecated in favor of `bit-table-scroll`. + ## Accessibility - Always include a row or column header with your table; this allows assistive technology to better diff --git a/libs/components/src/table/table.stories.ts b/libs/components/src/table/table.stories.ts index e8ab24ee8b7..d696e6077dd 100644 --- a/libs/components/src/table/table.stories.ts +++ b/libs/components/src/table/table.stories.ts @@ -1,6 +1,13 @@ +import { RouterTestingModule } from "@angular/router/testing"; import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + import { countries } from "../form/countries"; +import { LayoutComponent } from "../layout"; +import { mockLayoutI18n } from "../layout/mocks"; +import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; +import { I18nMockService } from "../utils"; import { TableDataSource } from "./table-data-source"; import { TableModule } from "./table.module"; @@ -8,8 +15,17 @@ import { TableModule } from "./table.module"; export default { title: "Component Library/Table", decorators: [ + positionFixedWrapperDecorator(), moduleMetadata({ - imports: [TableModule], + imports: [TableModule, LayoutComponent, RouterTestingModule], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService(mockLayoutI18n); + }, + }, + ], }), ], argTypes: { @@ -116,18 +132,20 @@ export const Scrollable: Story = { trackBy: (index: number, item: any) => item.id, }, template: ` - - - Id - Name - Other - - - {{ row.id }} - {{ row.name }} - {{ row.other }} - - + + + + Id + Name + Other + + + {{ row.id }} + {{ row.name }} + {{ row.other }} + + + `, }), }; @@ -144,17 +162,19 @@ export const Filterable: Story = { sortFn: (a: any, b: any) => a.id - b.id, }, template: ` - - - - Name - Value - - - {{ row.name }} - {{ row.value }} - - + + + + + Name + Value + + + {{ row.name }} + {{ row.value }} + + + `, }), }; diff --git a/libs/components/src/utils/has-scrolled-from.ts b/libs/components/src/utils/has-scrolled-from.ts new file mode 100644 index 00000000000..44c73465bdd --- /dev/null +++ b/libs/components/src/utils/has-scrolled-from.ts @@ -0,0 +1,41 @@ +import { CdkScrollable } from "@angular/cdk/scrolling"; +import { Signal, inject, signal } from "@angular/core"; +import { toObservable, toSignal } from "@angular/core/rxjs-interop"; +import { map, startWith, switchMap } from "rxjs"; + +export type ScrollState = { + /** `true` when the scrollbar is not at the top-most position */ + top: boolean; + + /** `true` when the scrollbar is not at the bottom-most position */ + bottom: boolean; +}; + +/** + * Check if a `CdkScrollable` instance has been scrolled + * @param scrollable The instance to check, defaults to the one provided by the current injector + * @returns {Signal} + */ +export const hasScrolledFrom = (scrollable?: Signal): Signal => { + const _scrollable = scrollable ?? signal(inject(CdkScrollable)); + const scrollable$ = toObservable(_scrollable); + + const scrollState$ = scrollable$.pipe( + switchMap((_scrollable) => + _scrollable.elementScrolled().pipe( + startWith(null), + map(() => ({ + top: _scrollable.measureScrollOffset("top") > 0, + bottom: _scrollable.measureScrollOffset("bottom") > 0, + })), + ), + ), + ); + + return toSignal(scrollState$, { + initialValue: { + top: false, + bottom: false, + }, + }); +}; diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 89796148e23..cd731629b48 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -16,7 +16,6 @@ import { } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; import { LogoutService, PinServiceAbstraction } from "@bitwarden/auth/common"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; @@ -42,6 +41,7 @@ import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/pass import { UserKey } from "@bitwarden/common/types/key"; import { AsyncActionsModule, + AnonLayoutWrapperDataService, ButtonModule, DialogService, FormFieldModule, diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 7229b558f30..b39bb85ab30 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -24,10 +24,6 @@ export * as VaultIcons from "./icons"; export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service"; export { SshImportPromptService } from "./services/ssh-import-prompt.service"; -export { - RestrictedItemTypesService, - RestrictedCipherType, -} from "./services/restricted-item-types.service"; export * from "./abstractions/change-login-password.service"; export * from "./services/default-change-login-password.service"; diff --git a/package-lock.json b/package-lock.json index e0d40c8cc99..19ea5740057 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.198", + "@bitwarden/sdk-internal": "0.2.0-main.203", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "3.1.0", @@ -85,7 +85,7 @@ "@babel/core": "7.24.9", "@babel/preset-env": "7.24.8", "@compodoc/compodoc": "1.1.26", - "@electron/notarize": "2.5.0", + "@electron/notarize": "3.0.1", "@electron/rebuild": "3.7.2", "@eslint/compat": "1.2.9", "@lit-labs/signals": "0.1.2", @@ -4378,9 +4378,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.198", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.198.tgz", - "integrity": "sha512-/MRdlcBqGxFEK/p6bU4hu5ZRoa+PqU88S+xnQaFrCXsWCTXrC8Nvm46iiz6gAqdbfFQWFNLCtmoNx6LFUdRuNg==", + "version": "0.2.0-main.203", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.203.tgz", + "integrity": "sha512-AcRX2odnabnx16VF+K7naEZ3R4Tv/o8mVsVhrvwOTG+TEBUxR1BzCoE2r+l0+iz1zV32UV2YHeLZvyCB2/KftA==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" @@ -5402,34 +5402,17 @@ } }, "node_modules/@electron/notarize": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", - "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.0.1.tgz", + "integrity": "sha512-5xzcOwvMGNjkSk7s0sPx4XcKWei9FYk4f2S5NkSorWW0ce5yktTOtlPa0W5yQHcREILh+C3JdH+t+M637g9TmQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.1", + "debug": "^4.4.0", "promise-retry": "^2.0.1" }, "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/notarize/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" + "node": ">= 22.12.0" } }, "node_modules/@electron/osx-sign": { @@ -14324,6 +14307,37 @@ "electron-builder-squirrel-windows": "26.0.12" } }, + "node_modules/app-builder-lib/node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/app-builder-lib/node_modules/@electron/rebuild": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.0.tgz", diff --git a/package.json b/package.json index 3304d168259..888e0c24329 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@babel/core": "7.24.9", "@babel/preset-env": "7.24.8", "@compodoc/compodoc": "1.1.26", - "@electron/notarize": "2.5.0", + "@electron/notarize": "3.0.1", "@electron/rebuild": "3.7.2", "@eslint/compat": "1.2.9", "@lit-labs/signals": "0.1.2", @@ -160,7 +160,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.198", + "@bitwarden/sdk-internal": "0.2.0-main.203", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "3.1.0",