diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 59ef1e0734e..c96dae51c0e 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -10,79 +10,44 @@ on: pull_request: types: [opened, synchronize, reopened] branches-ignore: - - main + - "main" pull_request_target: types: [opened, synchronize, reopened] branches: - "main" +permissions: {} + jobs: check-run: name: Check PR run uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + permissions: + contents: read sast: - name: SAST scan - runs-on: ubuntu-22.04 + name: Checkmarx + uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main needs: check-run + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: contents: read pull-requests: write security-events: write - - steps: - - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Scan with Checkmarx - uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41 - env: - INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}" - with: - project_name: ${{ github.repository }} - cx_tenant: ${{ secrets.CHECKMARX_TENANT }} - base_uri: https://ast.checkmarx.net/ - cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} - cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} - additional_params: | - --report-format sarif \ - --filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \ - --output-path . ${{ env.INCREMENTAL }} - - - name: Upload Checkmarx results to GitHub - uses: github/codeql-action/upload-sarif@d68b2d4edb4189fd2a5366ac14e72027bd4b37dd # v3.28.2 - with: - sarif_file: cx_result.sarif - sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} - ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} + id-token: write quality: - name: Quality scan - runs-on: ubuntu-22.04 + name: Sonar + uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main needs: check-run + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: contents: read pull-requests: write - - steps: - - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - - name: Scan with SonarCloud - uses: sonarsource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf # v5.2.0 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - with: - args: > - -Dsonar.organization=${{ github.repository_owner }} - -Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }} - -Dsonar.tests=. - -Dsonar.sources=. - -Dsonar.test.inclusions=**/*.spec.ts - -Dsonar.exclusions=**/*.spec.ts - -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} + id-token: write \ 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 ee8cd412625..37d64c3416b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index d835497d9be..3de1cc81a69 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -102,6 +102,18 @@ + + + {{ "manageDevices" | i18n }} + + + + {{ "devices" | i18n }} + + + + + {{ "otherOptions" | i18n }} 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 4f9e1f7414a..6c072532a5d 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -32,6 +32,7 @@ import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/ import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { VaultTimeout, VaultTimeoutAction, @@ -40,6 +41,7 @@ import { VaultTimeoutSettingsService, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -113,6 +115,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { biometricUnavailabilityReason: string; showChangeMasterPass = true; pinEnabled$: Observable = of(true); + extensionLoginApprovalFlagEnabled = false; form = this.formBuilder.group({ vaultTimeout: [null as VaultTimeout | null], @@ -155,6 +158,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { private biometricsService: BiometricsService, private vaultNudgesService: NudgesService, private validationService: ValidationService, + private configService: ConfigService, ) {} async ngOnInit() { @@ -235,6 +239,10 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { }; this.form.patchValue(initialValues, { emitEvent: false }); + this.extensionLoginApprovalFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM14938_BrowserExtensionLoginApproval, + ); + timer(0, 1000) .pipe( switchMap(async () => { diff --git a/apps/browser/src/auth/popup/settings/extension-device-management.component.html b/apps/browser/src/auth/popup/settings/extension-device-management.component.html new file mode 100644 index 00000000000..aadbe6b81d0 --- /dev/null +++ b/apps/browser/src/auth/popup/settings/extension-device-management.component.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/browser/src/auth/popup/settings/extension-device-management.component.ts b/apps/browser/src/auth/popup/settings/extension-device-management.component.ts new file mode 100644 index 00000000000..793965db141 --- /dev/null +++ b/apps/browser/src/auth/popup/settings/extension-device-management.component.ts @@ -0,0 +1,22 @@ +import { Component } from "@angular/core"; + +import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component"; +import { I18nPipe } from "@bitwarden/ui-common"; + +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"; + +@Component({ + standalone: true, + selector: "extension-device-management", + templateUrl: "extension-device-management.component.html", + imports: [ + DeviceManagementComponent, + I18nPipe, + PopOutComponent, + PopupHeaderComponent, + PopupPageComponent, + ], +}) +export class ExtensionDeviceManagementComponent {} diff --git a/apps/browser/src/auth/services/extension-device-management-component.service.ts b/apps/browser/src/auth/services/extension-device-management-component.service.ts new file mode 100644 index 00000000000..2585ba3198c --- /dev/null +++ b/apps/browser/src/auth/services/extension-device-management-component.service.ts @@ -0,0 +1,15 @@ +import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; + +/** + * Browser extension implementation of the device management component service + */ +export class ExtensionDeviceManagementComponentService + implements DeviceManagementComponentServiceAbstraction +{ + /** + * Don't show header information in browser extension client + */ + showHeaderInformation(): boolean { + return false; + } +} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 014174aa22c..91a32294060 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -817,8 +817,9 @@ export default class MainBackground { ); this.devicesService = new DevicesServiceImplementation( - this.devicesApiService, this.appIdService, + this.devicesApiService, + this.i18nService, ); this.authRequestApiService = new DefaultAuthRequestApiService(this.apiService, this.logService); diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index da5a6c43d36..9e55cfce2ce 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -12,6 +12,7 @@ import { authGuard, lockGuard, redirectGuard, + redirectToVaultIfUnlockedGuard, tdeDecryptionRequiredGuard, unauthGuardFn, } from "@bitwarden/angular/auth/guards"; @@ -49,6 +50,7 @@ import { AccountSwitcherComponent } from "../auth/popup/account-switching/accoun 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"; +import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { Fido2Component } from "../autofill/popup/fido2/fido2.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; @@ -263,6 +265,12 @@ const routes: Routes = [ canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, + { + path: "device-management", + component: ExtensionDeviceManagementComponent, + canActivate: [authGuard], + data: { elevation: 1 } satisfies RouteDataProperties, + }, { path: "notifications", component: NotificationsSettingsComponent, @@ -447,6 +455,7 @@ const routes: Routes = [ }, { path: "login-with-device", + canActivate: [redirectToVaultIfUnlockedGuard()], data: { pageIcon: DevicesIcon, pageTitle: { @@ -495,6 +504,7 @@ const routes: Routes = [ }, { path: "admin-approval-requested", + canActivate: [redirectToVaultIfUnlockedGuard()], data: { pageIcon: DevicesIcon, pageTitle: { diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 54d09ab9d8c..3887c8c8b12 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -4,6 +4,7 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; import { merge, of, Subject } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; +import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; @@ -145,6 +146,7 @@ import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock 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"; +import { ExtensionDeviceManagementComponentService } from "../../auth/services/extension-device-management-component.service"; import { ExtensionTwoFactorAuthComponentService } from "../../auth/services/extension-two-factor-auth-component.service"; import { ExtensionTwoFactorAuthDuoComponentService } from "../../auth/services/extension-two-factor-auth-duo-component.service"; import { ExtensionTwoFactorAuthWebAuthnComponentService } from "../../auth/services/extension-two-factor-auth-webauthn-component.service"; @@ -667,6 +669,11 @@ const safeProviders: SafeProvider[] = [ useClass: ForegroundNotificationsService, deps: [LogService], }), + safeProvider({ + provide: DeviceManagementComponentServiceAbstraction, + useClass: ExtensionDeviceManagementComponentService, + deps: [], + }), ]; @NgModule({ diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts index aa790d24ede..1eef907821d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -6,12 +6,13 @@ import { combineLatest, map, Observable, startWith } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { IconButtonModule, TypographyModule } from "@bitwarden/components"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; -import { PopupCipherView } from "../../../views/popup-cipher.view"; +import { PopupCipherViewLike } from "../../../views/popup-cipher.view"; import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component"; @Component({ @@ -30,7 +31,7 @@ export class AutofillVaultListItemsComponent { * The list of ciphers that can be used to autofill the current page. * @protected */ - protected autofillCiphers$: Observable = + protected autofillCiphers$: Observable = this.vaultPopupItemsService.autoFillCiphers$; /** @@ -62,7 +63,9 @@ export class AutofillVaultListItemsComponent { ]).pipe( map( ([hasFilter, ciphers, canAutoFill]) => - !hasFilter && canAutoFill && ciphers.filter((c) => c.type == CipherType.Login).length === 0, + !hasFilter && + canAutoFill && + ciphers.filter((c) => CipherViewLikeUtils.getType(c) == CipherType.Login).length === 0, ), ); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html index 576f6b7def6..567d5277454 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html @@ -1,4 +1,4 @@ - + - + - + - + - + - + - + - + !!value); + findSingleCopyableItem(items: CipherItem[]): CipherItem | null { + const itemsWithValue = items.filter(({ field }) => + CipherViewLikeUtils.hasCopyableValue(this.cipher, field), + ); return itemsWithValue.length === 1 ? { ...itemsWithValue[0], key: this.i18nService.t(itemsWithValue[0].key) } : null; } + get hasLoginValues() { + return this.getNumberOfLoginValues() > 0; + } + get hasCardValues() { - return !!this.cipher.card.code || !!this.cipher.card.number; + return this.getNumberOfCardValues() > 0; } get hasIdentityValues() { - return ( - !!this.cipher.identity.fullAddressForCopy || - !!this.cipher.identity.email || - !!this.cipher.identity.username || - !!this.cipher.identity.phone - ); + return this.getNumberOfIdentityValues() > 0; } get hasSecureNoteValue() { - return !!this.cipher.notes; + return this.getNumberOfSecureNoteValues() > 0; } get hasSshKeyValues() { - return ( - !!this.cipher.sshKey.privateKey || - !!this.cipher.sshKey.publicKey || - !!this.cipher.sshKey.keyFingerprint - ); + return this.getNumberOfSshKeyValues() > 0; } constructor(private i18nService: I18nService) {} + + /** Sets the number of populated login values for the cipher */ + private getNumberOfLoginValues() { + if (CipherViewLikeUtils.isCipherListView(this.cipher)) { + const copyableLoginFields: CopyableCipherFields[] = [ + "LoginUsername", + "LoginPassword", + "LoginTotp", + ]; + return this.cipher.copyableFields.filter((field) => copyableLoginFields.includes(field)) + .length; + } + + return [this.cipher.login.username, this.cipher.login.password, this.cipher.login.totp].filter( + Boolean, + ).length; + } + + /** Sets the number of populated card values for the cipher */ + private getNumberOfCardValues() { + if (CipherViewLikeUtils.isCipherListView(this.cipher)) { + const copyableCardFields: CopyableCipherFields[] = ["CardSecurityCode", "CardNumber"]; + return this.cipher.copyableFields.filter((field) => copyableCardFields.includes(field)) + .length; + } + + return [this.cipher.card.code, this.cipher.card.number].filter(Boolean).length; + } + + /** Sets the number of populated identity values for the cipher */ + private getNumberOfIdentityValues() { + if (CipherViewLikeUtils.isCipherListView(this.cipher)) { + const copyableIdentityFields: CopyableCipherFields[] = [ + "IdentityAddress", + "IdentityEmail", + "IdentityUsername", + "IdentityPhone", + ]; + return this.cipher.copyableFields.filter((field) => copyableIdentityFields.includes(field)) + .length; + } + + return [ + this.cipher.identity.fullAddressForCopy, + this.cipher.identity.email, + this.cipher.identity.username, + this.cipher.identity.phone, + ].filter(Boolean).length; + } + /** Sets the number of populated secure note values for the cipher */ + private getNumberOfSecureNoteValues(): number { + if (CipherViewLikeUtils.isCipherListView(this.cipher)) { + return this.cipher.copyableFields.includes("SecureNotes") ? 1 : 0; + } + + return this.cipher.notes ? 1 : 0; + } + + /** Sets the number of populated SSH key values for the cipher */ + private getNumberOfSshKeyValues() { + if (CipherViewLikeUtils.isCipherListView(this.cipher)) { + return this.cipher.copyableFields.includes("SshKey") ? 1 : 0; + } + + return [ + this.cipher.sshKey.privateKey, + this.cipher.sshKey.publicKey, + this.cipher.sshKey.keyFingerprint, + ].filter(Boolean).length; + } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index f9be1617d21..962f0c914f5 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -5,7 +5,7 @@ size="small" [attr.aria-label]="'moreOptionsLabel' | i18n: cipher.name" [title]="'moreOptionsTitle' | i18n: cipher.name" - [disabled]="cipher.decryptionFailure" + [disabled]="decryptionFailure" [bitMenuTriggerFor]="moreOptions" > diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 75bc984e977..ce61e29e9ef 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -14,8 +14,11 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { DialogService, IconButtonModule, @@ -34,12 +37,12 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], }) export class ItemMoreOptionsComponent { - private _cipher$ = new BehaviorSubject(undefined); + private _cipher$ = new BehaviorSubject(undefined); @Input({ required: true, }) - set cipher(c: CipherView) { + set cipher(c: CipherViewLike) { this._cipher$.next(c); } @@ -109,17 +112,22 @@ export class ItemMoreOptionsComponent { get canViewPassword() { return this.cipher.viewPassword; } + + get decryptionFailure() { + return CipherViewLikeUtils.decryptionFailure(this.cipher); + } + /** * Determines if the cipher can be autofilled. */ get canAutofill() { return ([CipherType.Login, CipherType.Card, CipherType.Identity] as CipherType[]).includes( - this.cipher.type, + CipherViewLikeUtils.getType(this.cipher), ); } get isLogin() { - return this.cipher.type === CipherType.Login; + return CipherViewLikeUtils.getType(this.cipher) === CipherType.Login; } get favoriteText() { @@ -127,11 +135,13 @@ export class ItemMoreOptionsComponent { } async doAutofill() { - await this.vaultPopupAutofillService.doAutofill(this.cipher); + const cipher = await this.cipherService.getFullCipherView(this.cipher); + await this.vaultPopupAutofillService.doAutofill(cipher); } async doAutofillAndSave() { - await this.vaultPopupAutofillService.doAutofillAndSave(this.cipher, false); + const cipher = await this.cipherService.getFullCipherView(this.cipher); + await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false); } async onView() { @@ -140,7 +150,7 @@ export class ItemMoreOptionsComponent { return; } await this.router.navigate(["/view-cipher"], { - queryParams: { cipherId: this.cipher.id, type: this.cipher.type }, + queryParams: { cipherId: this.cipher.id, type: CipherViewLikeUtils.getType(this.cipher) }, }); } @@ -148,11 +158,14 @@ export class ItemMoreOptionsComponent { * Toggles the favorite status of the cipher and updates it on the server. */ async toggleFavorite() { - this.cipher.favorite = !this.cipher.favorite; + const cipher = await this.cipherService.getFullCipherView(this.cipher); + + cipher.favorite = !cipher.favorite; const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); - const encryptedCipher = await this.cipherService.encrypt(this.cipher, activeUserId); + + const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId); await this.cipherService.updateWithServer(encryptedCipher); this.toastService.showToast({ variant: "success", @@ -176,7 +189,7 @@ export class ItemMoreOptionsComponent { return; } - if (this.cipher.login?.hasFido2Credentials) { + if (CipherViewLikeUtils.hasFido2Credentials(this.cipher)) { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "passkeyNotCopied" }, content: { key: "passkeyNotCopiedAlert" }, @@ -192,7 +205,7 @@ export class ItemMoreOptionsComponent { queryParams: { clone: true.toString(), cipherId: this.cipher.id, - type: this.cipher.type.toString(), + type: CipherViewLikeUtils.getType(this.cipher).toString(), } as AddEditQueryParams, }); } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts index b65138dac3a..b5d35e2005e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts @@ -76,8 +76,10 @@ describe("VaultGeneratorDialogComponent", () => { component.onValueGenerated("test-password"); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); - expect(button.attributes["aria-disabled"]).toBe(undefined); + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(false); }); it("should disable the button if no value has been generated", () => { @@ -88,8 +90,10 @@ describe("VaultGeneratorDialogComponent", () => { generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); - expect(button.attributes["aria-disabled"]).toBe("true"); + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(true); }); it("should disable the button if no algorithm is selected", () => { @@ -100,8 +104,10 @@ describe("VaultGeneratorDialogComponent", () => { generator.valueGenerated.emit("test-password"); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); - expect(button.attributes["aria-disabled"]).toBe("true"); + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(true); }); it("should update button text when algorithm is selected", () => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index 8dca1f9e576..b012b7bf157 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -97,7 +97,8 @@ (click)="primaryActionOnSelect(cipher)" (dblclick)="launchCipher(cipher)" [appA11yTitle]=" - cipherItemTitleKey()(cipher) | i18n: cipher.name : cipher.login.username + cipherItemTitleKey()(cipher) + | i18n: cipher.name : CipherViewLikeUtils.getLogin(cipher)?.username " class="{{ itemHeightClass }}" > @@ -114,11 +115,11 @@ [appA11yTitle]="orgIconTooltip(cipher)" > - {{ cipher.subTitle }} + {{ CipherViewLikeUtils.subtitle(cipher) }} @@ -134,7 +135,7 @@ {{ "fill" | i18n }} - + ([]); + ciphers = input([]); /** * If true, we will group ciphers by type (Login, Card, Identity) @@ -139,7 +143,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit { cipherGroups = computed< { subHeaderKey?: string; - ciphers: PopupCipherView[]; + ciphers: PopupCipherViewLike[]; }[] >(() => { // Not grouping by type, return a single group with all ciphers @@ -147,11 +151,11 @@ export class VaultListItemsContainerComponent implements AfterViewInit { return [{ ciphers: this.ciphers() }]; } - const groups: Record = {}; + const groups: Record = {}; this.ciphers().forEach((cipher) => { let groupKey = "all"; - switch (cipher.type) { + switch (CipherViewLikeUtils.getType(cipher)) { case CipherType.Card: groupKey = "cards"; break; @@ -212,8 +216,9 @@ export class VaultListItemsContainerComponent implements AfterViewInit { * Resolved i18n key to use for suggested cipher items */ cipherItemTitleKey = computed(() => { - return (cipher: CipherView) => { - const hasUsername = cipher.login?.username != null; + return (cipher: CipherViewLike) => { + const login = CipherViewLikeUtils.getLogin(cipher); + const hasUsername = login?.username != null; const key = this.primaryActionAutofill() && !this.currentURIIsBlocked() ? "autofillTitle" @@ -259,12 +264,12 @@ export class VaultListItemsContainerComponent implements AfterViewInit { * The tooltip text for the organization icon for ciphers that belong to an organization. * @param cipher */ - orgIconTooltip(cipher: PopupCipherView) { - if (cipher.collectionIds.length > 1 || !cipher.collections) { - return this.i18nService.t("nCollections", cipher.collectionIds.length); + orgIconTooltip({ collectionIds, collections }: PopupCipherViewLike) { + if (collectionIds.length > 1 || !collections) { + return this.i18nService.t("nCollections", collectionIds.length); } - return cipher.collections[0]?.name; + return collections[0]?.name; } protected autofillShortcutTooltip = signal(undefined); @@ -292,7 +297,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit { } } - primaryActionOnSelect(cipher: CipherView) { + primaryActionOnSelect(cipher: PopupCipherViewLike) { return this.primaryActionAutofill() && !this.currentURIIsBlocked() ? this.doAutofill(cipher) : this.onViewCipher(cipher); @@ -301,8 +306,9 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Launches the login cipher in a new browser tab. */ - async launchCipher(cipher: CipherView) { - if (!cipher.canLaunch) { + async launchCipher(cipher: CipherViewLike) { + const launchURI = CipherViewLikeUtils.getLaunchUri(cipher); + if (!CipherViewLikeUtils.canLaunch(cipher) || !launchURI) { return; } @@ -313,20 +319,30 @@ export class VaultListItemsContainerComponent implements AfterViewInit { } const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherService.updateLastLaunchedDate(cipher.id, activeUserId); + await this.cipherService.updateLastLaunchedDate(cipher.id!, activeUserId); - await BrowserApi.createNewTab(cipher.login.launchUri); + await BrowserApi.createNewTab(launchURI); if (BrowserPopupUtils.inPopup(window)) { BrowserApi.closePopup(window); } } - async doAutofill(cipher: PopupCipherView) { - await this.vaultPopupAutofillService.doAutofill(cipher); + async doAutofill(cipher: PopupCipherViewLike) { + if (!CipherViewLikeUtils.isCipherListView(cipher)) { + await this.vaultPopupAutofillService.doAutofill(cipher); + return; + } + + // When only the `CipherListView` is available, fetch the full cipher details + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const _cipher = await this.cipherService.get(cipher.id!, activeUserId); + const cipherView = await this.cipherService.decrypt(_cipher, activeUserId); + + await this.vaultPopupAutofillService.doAutofill(cipherView); } - async onViewCipher(cipher: PopupCipherView) { + async onViewCipher(cipher: PopupCipherViewLike) { // We already have a view action in progress, don't start another if (this.viewCipherTimeout != null) { return; @@ -336,7 +352,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit { this.viewCipherTimeout = window.setTimeout( async () => { try { - if (cipher.decryptionFailure) { + if (CipherViewLikeUtils.decryptionFailure(cipher)) { DecryptionFailureDialogComponent.open(this.dialogService, { cipherIds: [cipher.id as CipherId], }); @@ -355,7 +371,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit { this.viewCipherTimeout = undefined; } }, - cipher.canLaunch ? 200 : 0, + CipherViewLikeUtils.canLaunch(cipher) ? 200 : 0, ); } diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 28bf710ec60..48788ea5ae9 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -1,7 +1,7 @@ import { WritableSignal, signal } from "@angular/core"; import { TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, timeout } from "rxjs"; +import { BehaviorSubject, firstValueFrom, of, take, timeout } from "rxjs"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -23,10 +23,12 @@ import { RestrictedCipherType, RestrictedItemTypesService, } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { InlineMenuFieldQualificationService } from "../../../autofill/services/inline-menu-field-qualification.service"; import { BrowserApi } from "../../../platform/browser/browser-api"; import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service"; +import { PopupCipherViewLike } from "../views/popup-cipher.view"; import { VaultPopupAutofillService } from "./vault-popup-autofill.service"; import { VaultPopupItemsService } from "./vault-popup-items.service"; @@ -80,7 +82,9 @@ describe("VaultPopupItemsService", () => { cipherList[2].favorite = true; cipherList[3].favorite = true; - cipherServiceMock.getAllDecrypted.mockResolvedValue(cipherList); + const cipherList$ = new BehaviorSubject(cipherList); + + cipherServiceMock.cipherListViews$.mockReturnValue(cipherList$.asObservable()); ciphersSubject = new BehaviorSubject>({}); localDataSubject = new BehaviorSubject>({}); @@ -111,7 +115,7 @@ describe("VaultPopupItemsService", () => { }); // Return all ciphers, `filterFunction$` will be tested in `VaultPopupListFiltersService` vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject( - (ciphers: CipherView[]) => ciphers, + (ciphers: PopupCipherViewLike[]) => ciphers, ); vaultAutofillServiceMock.currentAutofillTab$ = new BehaviorSubject({ @@ -279,7 +283,9 @@ describe("VaultPopupItemsService", () => { const current = ciphers[i]; const next = ciphers[i + 1]; - expect(expectedTypeOrder[current.type]).toBeLessThanOrEqual(expectedTypeOrder[next.type]); + expect(expectedTypeOrder[CipherViewLikeUtils.getType(current)]).toBeLessThanOrEqual( + expectedTypeOrder[CipherViewLikeUtils.getType(next)], + ); } expect(cipherServiceMock.sortCiphersByLastUsedThenName).toHaveBeenCalled(); done(); @@ -365,28 +371,34 @@ describe("VaultPopupItemsService", () => { describe("emptyVault$", () => { it("should return true if there are no ciphers", (done) => { - cipherServiceMock.getAllDecrypted.mockResolvedValue([]); - service.emptyVault$.subscribe((empty) => { + cipherServiceMock.cipherListViews$.mockReturnValue(of([])); + service.emptyVault$.pipe(take(1)).subscribe((empty) => { expect(empty).toBe(true); done(); }); }); it("should return false if there are ciphers", (done) => { - service.emptyVault$.subscribe((empty) => { + cipherServiceMock.cipherListViews$.mockReturnValue( + of([{ id: "1", type: CipherType.Login, name: "Login 1" }] as CipherView[]), + ); + + service.emptyVault$.pipe(take(1)).subscribe((empty) => { expect(empty).toBe(false); done(); }); }); it("should return true when all ciphers are deleted", (done) => { - cipherServiceMock.getAllDecrypted.mockResolvedValue([ - { id: "1", type: CipherType.Login, name: "Login 1", isDeleted: true }, - { id: "2", type: CipherType.Login, name: "Login 2", isDeleted: true }, - { id: "3", type: CipherType.Login, name: "Login 3", isDeleted: true }, - ] as CipherView[]); + cipherServiceMock.cipherListViews$.mockReturnValue( + of([ + { id: "1", type: CipherType.Login, name: "Login 1", isDeleted: true }, + { id: "2", type: CipherType.Login, name: "Login 2", isDeleted: true }, + { id: "3", type: CipherType.Login, name: "Login 3", isDeleted: true }, + ] as CipherView[]), + ); - service.emptyVault$.subscribe((empty) => { + service.emptyVault$.pipe(take(1)).subscribe((empty) => { expect(empty).toBe(true); done(); }); @@ -416,8 +428,7 @@ describe("VaultPopupItemsService", () => { deletedCipher.deletedDate = new Date(); const ciphers = [new CipherView(), new CipherView(), new CipherView(), deletedCipher]; - cipherServiceMock.getAllDecrypted.mockResolvedValue(ciphers); - + cipherServiceMock.cipherListViews$.mockReturnValue(of(ciphers)); ciphersSubject.next({}); const deletedCiphers = await firstValueFrom(service.deletedCiphers$); diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index d47abb9e6b3..b2d4fd1b262 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -23,20 +23,22 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SyncService } from "@bitwarden/common/platform/sync"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator"; import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service"; import { waitUntil } from "../../util"; -import { PopupCipherView } from "../views/popup-cipher.view"; +import { PopupCipherViewLike } from "../views/popup-cipher.view"; import { VaultPopupAutofillService } from "./vault-popup-autofill.service"; import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; @@ -96,49 +98,52 @@ export class VaultPopupItemsService { * Observable that contains the list of all decrypted ciphers. * @private */ - private _allDecryptedCiphers$: Observable = this.accountService.activeAccount$.pipe( - map((a) => a?.id), - filter((userId): userId is UserId => userId != null), - switchMap((userId) => - merge(this.cipherService.ciphers$(userId), this.cipherService.localData$(userId)).pipe( - runInsideAngular(this.ngZone), - tap(() => this._ciphersLoading$.next()), - waitUntilSync(this.syncService), - switchMap(() => - combineLatest([ - Utils.asyncToObservable(() => this.cipherService.getAllDecrypted(userId)), - this.cipherService.failedToDecryptCiphers$(userId), - this.restrictedItemTypesService.restricted$.pipe(startWith([])), - ]), + private _allDecryptedCiphers$: Observable = + this.accountService.activeAccount$.pipe( + map((a) => a?.id), + filter((userId): userId is UserId => userId != null), + switchMap((userId) => + merge(this.cipherService.ciphers$(userId), this.cipherService.localData$(userId)).pipe( + runInsideAngular(this.ngZone), + tap(() => this._ciphersLoading$.next()), + waitUntilSync(this.syncService), + switchMap(() => + combineLatest([ + this.cipherService + .cipherListViews$(userId) + .pipe(filter((ciphers) => ciphers != null)), + this.cipherService.failedToDecryptCiphers$(userId), + this.restrictedItemTypesService.restricted$.pipe(startWith([])), + ]), + ), + map(([ciphers, failedToDecryptCiphers, restrictions]) => { + const allCiphers = [...(failedToDecryptCiphers || []), ...ciphers]; + + return allCiphers.filter( + (cipher) => !this.restrictedItemTypesService.isCipherRestricted(cipher, restrictions), + ); + }), ), - map(([ciphers, failedToDecryptCiphers, restrictions]) => { - const allCiphers = [...(failedToDecryptCiphers || []), ...ciphers]; - - return allCiphers.filter( - (cipher) => !this.restrictedItemTypesService.isCipherRestricted(cipher, restrictions), - ); - }), ), - ), - shareReplay({ refCount: true, bufferSize: 1 }), - ); + shareReplay({ refCount: true, bufferSize: 1 }), + ); - private _activeCipherList$: Observable = this._allDecryptedCiphers$.pipe( + private _activeCipherList$: Observable = this._allDecryptedCiphers$.pipe( switchMap((ciphers) => combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe( map(([organizations, collections]) => { const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); return ciphers - .filter((c) => !c.isDeleted) - .map( - (cipher) => - new PopupCipherView( - cipher, - cipher.collectionIds?.map((colId) => collectionMap[colId as CollectionId]), - orgMap[cipher.organizationId as OrganizationId], - ), - ); + .filter((c) => !CipherViewLikeUtils.isDeleted(c)) + .map((cipher) => { + (cipher as PopupCipherViewLike).collections = cipher.collectionIds?.map( + (colId) => collectionMap[colId as CollectionId], + ); + (cipher as PopupCipherViewLike).organization = + orgMap[cipher.organizationId as OrganizationId]; + return cipher; + }); }), ), ), @@ -157,21 +162,23 @@ export class VaultPopupItemsService { }), ); - private _filteredCipherList$: Observable = combineLatest([ + private _filteredCipherList$: Observable = combineLatest([ this._activeCipherList$, this.searchText$, this.vaultPopupListFiltersService.filterFunction$, getUserId(this.accountService.activeAccount$), ]).pipe( - map(([ciphers, searchText, filterFunction, userId]): [CipherView[], string, UserId] => [ - filterFunction(ciphers), - searchText, - userId, - ]), + map( + ([ciphers, searchText, filterFunction, userId]): [PopupCipherViewLike[], string, UserId] => [ + filterFunction(ciphers), + searchText, + userId, + ], + ), switchMap( ([ciphers, searchText, userId]) => this.searchService.searchCiphers(userId, searchText, undefined, ciphers) as Promise< - PopupCipherView[] + PopupCipherViewLike[] >, ), shareReplay({ refCount: true, bufferSize: 1 }), @@ -183,7 +190,7 @@ export class VaultPopupItemsService { * * See {@link refreshCurrentTab} to trigger re-evaluation of the current tab. */ - autoFillCiphers$: Observable = combineLatest([ + autoFillCiphers$: Observable = combineLatest([ this._filteredCipherList$, this._otherAutoFillTypes$, this.vaultPopupAutofillService.currentAutofillTab$, @@ -202,7 +209,7 @@ export class VaultPopupItemsService { * List of favorite ciphers that are not currently suggested for autofill. * Ciphers are sorted by name. */ - favoriteCiphers$: Observable = this.autoFillCiphers$.pipe( + favoriteCiphers$: Observable = this.autoFillCiphers$.pipe( withLatestFrom(this._filteredCipherList$), map(([autoFillCiphers, ciphers]) => ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)), @@ -214,7 +221,7 @@ export class VaultPopupItemsService { * List of all remaining ciphers that are not currently suggested for autofill or marked as favorite. * Ciphers are sorted by name. */ - remainingCiphers$: Observable = this.favoriteCiphers$.pipe( + remainingCiphers$: Observable = this.favoriteCiphers$.pipe( concatMap( ( favoriteCiphers, // concatMap->of is used to make withLatestFrom lazy to avoid race conditions with autoFillCiphers$ @@ -282,21 +289,23 @@ export class VaultPopupItemsService { /** * Observable that contains the list of ciphers that have been deleted. */ - deletedCiphers$: Observable = this._allDecryptedCiphers$.pipe( + deletedCiphers$: Observable = this._allDecryptedCiphers$.pipe( switchMap((ciphers) => combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe( map(([organizations, collections]) => { const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); return ciphers - .filter((c) => c.isDeleted) + .filter((c) => CipherViewLikeUtils.isDeleted(c)) .map( (cipher) => - new PopupCipherView( - cipher, - cipher.collectionIds?.map((colId) => collectionMap[colId as CollectionId]), - orgMap[cipher.organizationId as OrganizationId], - ), + ({ + ...cipher, + collections: cipher.collectionIds?.map( + (colId) => collectionMap[colId as CollectionId], + ), + organization: orgMap[cipher.organizationId as OrganizationId], + }) as PopupCipherViewLike, ); }), ), @@ -327,7 +336,7 @@ export class VaultPopupItemsService { * Sorts by type, then by last used date, and finally by name. * @private */ - private sortCiphersForAutofill(a: CipherView, b: CipherView): number { + private sortCiphersForAutofill(a: CipherViewLike, b: CipherViewLike): number { const typeOrder = { [CipherType.Login]: 1, [CipherType.Card]: 2, @@ -336,10 +345,13 @@ export class VaultPopupItemsService { [CipherType.SshKey]: 5, } as Record; + const aType = CipherViewLikeUtils.getType(a); + const bType = CipherViewLikeUtils.getType(b); + // Compare types first - if (typeOrder[a.type] < typeOrder[b.type]) { + if (typeOrder[aType] < typeOrder[bType]) { return -1; - } else if (typeOrder[a.type] > typeOrder[b.type]) { + } else if (typeOrder[aType] > typeOrder[bType]) { return 1; } 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 e530046a971..9f1bd6e6e55 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 @@ -27,6 +27,8 @@ import { RestrictedItemTypesService, } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { PopupCipherViewLike } from "../views/popup-cipher.view"; + import { CachedFilterState, MY_VAULT_ID, @@ -47,7 +49,7 @@ describe("VaultPopupListFiltersService", () => { const memberOrganizations$ = (userId: UserId) => _memberOrganizations$; const organizations$ = new BehaviorSubject([]); let folderViews$ = new BehaviorSubject([]); - const cipherViews$ = new BehaviorSubject({}); + const cipherListViews$ = new BehaviorSubject({}); let decryptedCollections$ = new BehaviorSubject([]); const policyAppliesToUser$ = new BehaviorSubject(false); let viewCacheService: { @@ -65,7 +67,7 @@ describe("VaultPopupListFiltersService", () => { } as unknown as FolderService; const cipherService = { - cipherViews$: () => cipherViews$, + cipherListViews$: () => cipherListViews$, } as unknown as CipherService; const organizationService = { @@ -508,7 +510,7 @@ describe("VaultPopupListFiltersService", () => { { id: "2345", name: "Folder 2" }, ]); - cipherViews$.next({ + cipherListViews$.next({ "1": { folderId: "1234", organizationId: "1234" }, "2": { folderId: "2345", organizationId: "56789" }, }); @@ -566,6 +568,28 @@ describe("VaultPopupListFiltersService", () => { service.filterForm.patchValue({ organization }); }); + it("keeps ciphers with null and undefined for organizationId when MyVault is selected", (done) => { + const organization = { id: MY_VAULT_ID } as Organization; + + const undefinedOrgIdCipher = { + type: CipherType.SecureNote, + collectionIds: [], + organizationId: undefined, + } as unknown as PopupCipherViewLike; + + service.filterFunction$.subscribe((filterFunction) => { + expect(filterFunction([...ciphers, undefinedOrgIdCipher])).toEqual([ + ciphers[0], + ciphers[2], + ciphers[3], + undefinedOrgIdCipher, + ]); + done(); + }); + + service.filterForm.patchValue({ organization }); + }); + it("filters out ciphers that do not belong to the selected organization", (done) => { const organization = { id: "8978" } as Organization; @@ -717,7 +741,10 @@ function createSeededVaultPopupListFiltersService( collections: CollectionView[], folderViews: FolderView[], cachedState: CachedFilterState = {}, -): { service: VaultPopupListFiltersService; cachedSignal: WritableSignal } { +): { + service: VaultPopupListFiltersService; + cachedSignal: WritableSignal; +} { const seededMemberOrganizations$ = new BehaviorSubject(organizations); const seededCollections$ = new BehaviorSubject(collections); const seededFolderViews$ = new BehaviorSubject(folderViews); @@ -744,7 +771,7 @@ function createSeededVaultPopupListFiltersService( } as any; const cipherServiceMock = { - cipherViews$: () => new BehaviorSubject({}), + cipherListViews$: () => new BehaviorSubject({}), } as any; const i18nServiceMock = { 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 a936aaf86d9..9db5811d75c 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 @@ -40,13 +40,15 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; -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 { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; +import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { ChipSelectOption } from "@bitwarden/components"; +import { PopupCipherViewLike } from "../views/popup-cipher.view"; + const FILTER_VISIBILITY_KEY = new KeyDefinition(VAULT_SETTINGS_DISK, "filterVisibility", { deserializer: (obj) => obj, }); @@ -111,7 +113,7 @@ export class VaultPopupListFiltersService { /** * Static list of ciphers views used in synchronous context */ - private cipherViews: CipherView[] = []; + private cipherViews: PopupCipherViewLike[] = []; private activeUserId$ = this.accountService.activeAccount$.pipe( map((a) => a?.id), @@ -216,21 +218,22 @@ export class VaultPopupListFiltersService { filterVisibilityState$ = this.filterVisibilityState.state$; /** - * Observable whose value is a function that filters an array of `CipherView` objects based on the current filters + * Observable whose value is a function that filters an array of `PopupCipherViewLike` objects based on the current filters */ - filterFunction$: Observable<(ciphers: CipherView[]) => CipherView[]> = combineLatest([ - this.filters$, - ]).pipe( - map( - ([filters]) => - (ciphers: CipherView[]) => + filterFunction$: Observable<(ciphers: PopupCipherViewLike[]) => PopupCipherViewLike[]> = + this.filters$.pipe( + map( + (filters) => (ciphers: PopupCipherViewLike[]) => ciphers.filter((cipher) => { // Vault popup lists never shows deleted ciphers - if (cipher.isDeleted) { + if (CipherViewLikeUtils.isDeleted(cipher)) { return false; } - if (filters.cipherType !== null && cipher.type !== filters.cipherType) { + if ( + filters.cipherType !== null && + CipherViewLikeUtils.getType(cipher) !== filters.cipherType + ) { return false; } @@ -245,7 +248,7 @@ export class VaultPopupListFiltersService { const isMyVault = filters.organization?.id === MY_VAULT_ID; if (isMyVault) { - if (cipher.organizationId !== null) { + if (cipher.organizationId != null) { return false; } } else if (filters.organization) { @@ -256,8 +259,8 @@ export class VaultPopupListFiltersService { return true; }), - ), - ); + ), + ); /** * All available cipher types (filtered by policy restrictions) @@ -356,7 +359,7 @@ export class VaultPopupListFiltersService { folders$: Observable[]> = this.activeUserId$.pipe( switchMap((userId) => { // Observable of cipher views - const cipherViews$ = this.cipherService.cipherViews$(userId).pipe( + const cipherViews$ = this.cipherService.cipherListViews$(userId).pipe( map((ciphers) => { this.cipherViews = ciphers ? Object.values(ciphers) : []; return this.cipherViews; @@ -374,30 +377,36 @@ export class VaultPopupListFiltersService { this.folderService.folderViews$(userId), cipherViews$, ]).pipe( - map(([filters, folders, cipherViews]): [PopupListFilter, FolderView[], CipherView[]] => { - if (folders.length === 1 && folders[0].id === null) { - // Do not display folder selections when only the "no folder" option is available. - return [filters as PopupListFilter, [], cipherViews]; - } + map( + ([filters, folders, cipherViews]): [ + PopupListFilter, + FolderView[], + PopupCipherViewLike[], + ] => { + if (folders.length === 1 && folders[0].id === null) { + // Do not display folder selections when only the "no folder" option is available. + return [filters as PopupListFilter, [], cipherViews]; + } - // Sort folders by alphabetic name - folders.sort(Utils.getSortFunction(this.i18nService, "name")); - let arrangedFolders = folders; + // Sort folders by alphabetic name + folders.sort(Utils.getSortFunction(this.i18nService, "name")); + let arrangedFolders = folders; - const noFolder = folders.find((f) => f.id === null); + const noFolder = folders.find((f) => f.id === null); - if (noFolder) { - // Update `name` of the "no folder" option to "Items with no folder" - const updatedNoFolder = { - ...noFolder, - name: this.i18nService.t("itemsWithNoFolder"), - }; + if (noFolder) { + // Update `name` of the "no folder" option to "Items with no folder" + const updatedNoFolder = { + ...noFolder, + name: this.i18nService.t("itemsWithNoFolder"), + }; - // Move the "no folder" option to the end of the list - arrangedFolders = [...folders.filter((f) => f.id !== null), updatedNoFolder]; - } - return [filters as PopupListFilter, arrangedFolders, cipherViews]; - }), + // Move the "no folder" option to the end of the list + arrangedFolders = [...folders.filter((f) => f.id !== null), updatedNoFolder]; + } + return [filters as PopupListFilter, arrangedFolders, cipherViews]; + }, + ), map(([filters, folders, cipherViews]) => { const organizationId = filters.organization?.id ?? null; diff --git a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts index b4f7a87aa08..1676fea3c01 100644 --- a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts @@ -30,7 +30,7 @@ import { PasswordRepromptService, } from "@bitwarden/vault"; -import { PopupCipherView } from "../../views/popup-cipher.view"; +import { PopupCipherViewLike } from "../../views/popup-cipher.view"; @Component({ selector: "app-trash-list-items-container", @@ -54,7 +54,7 @@ export class TrashListItemsContainerComponent { * The list of trashed items to display. */ @Input() - ciphers: PopupCipherView[] = []; + ciphers: PopupCipherViewLike[] = []; @Input() headerText: string; @@ -73,12 +73,12 @@ export class TrashListItemsContainerComponent { /** * The tooltip text for the organization icon for ciphers that belong to an organization. */ - orgIconTooltip(cipher: PopupCipherView) { - if (cipher.collectionIds.length > 1) { - return this.i18nService.t("nCollections", cipher.collectionIds.length); + orgIconTooltip({ collections, collectionIds }: PopupCipherViewLike) { + if (collectionIds.length > 1) { + return this.i18nService.t("nCollections", collectionIds.length); } - return cipher.collections[0]?.name; + return collections[0]?.name; } async restore(cipher: CipherView) { diff --git a/apps/browser/src/vault/popup/views/popup-cipher.view.ts b/apps/browser/src/vault/popup/views/popup-cipher.view.ts index e364aeabc3d..6f85e7b6eb4 100644 --- a/apps/browser/src/vault/popup/views/popup-cipher.view.ts +++ b/apps/browser/src/vault/popup/views/popup-cipher.view.ts @@ -1,25 +1,17 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CollectionView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherListView } from "@bitwarden/sdk-internal"; -/** - * Extended cipher view for the popup. Includes the associated collections and organization - * if applicable. - */ -export class PopupCipherView extends CipherView { +interface CommonPopupCipherView { collections?: CollectionView[]; organization?: Organization; - - constructor( - cipher: CipherView, - collections: CollectionView[] = null, - organization: Organization = null, - ) { - super(); - Object.assign(this, cipher); - this.collections = collections; - this.organization = organization; - } } + +/** Extended view for the popup based off of `CipherView` */ +interface PopupCipherView extends CipherView, CommonPopupCipherView {} + +/** Extended view for the popup based off of `CipherListView` from the SDK. */ +interface PopupCipherListView extends CipherListView, CommonPopupCipherView {} + +export type PopupCipherViewLike = PopupCipherListView | PopupCipherView; diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 1b645c9e7e2..1ad0dcf308f 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Length" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html index f77f279a96f..7298133146c 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html @@ -1,6 +1,6 @@ - + {{ "shared" | i18n }} - + - {{ c.subTitle }} + {{ + CipherViewLikeUtils.subtitle(c) + }} 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 21b857b551a..000281c5807 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 @@ -9,8 +9,11 @@ import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angul import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { MenuModule } from "@bitwarden/components"; import { SearchBarService } from "../../../app/layout/search/search-bar.service"; @@ -20,7 +23,8 @@ import { SearchBarService } from "../../../app/layout/search/search-bar.service" templateUrl: "vault-items-v2.component.html", imports: [MenuModule, CommonModule, JslibModule, ScrollingModule], }) -export class VaultItemsV2Component extends BaseVaultItemsComponent { +export class VaultItemsV2Component extends BaseVaultItemsComponent { + protected CipherViewLikeUtils = CipherViewLikeUtils; constructor( searchService: SearchService, private readonly searchBarService: SearchBarService, @@ -37,7 +41,7 @@ export class VaultItemsV2Component extends BaseVaultItemsComponent { }); } - trackByFn(index: number, c: CipherView): string { - return c.id; + trackByFn(index: number, c: C): string { + return c.id!; } } 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 6eb6b737899..62ca41a3379 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -40,6 +40,10 @@ import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { BadgeModule, ButtonModule, @@ -124,9 +128,11 @@ const BroadcasterSubscriptionId = "VaultComponent"; }, ], }) -export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { +export class VaultV2Component + implements OnInit, OnDestroy, CopyClickListener +{ @ViewChild(VaultItemsV2Component, { static: true }) - vaultItemsComponent: VaultItemsV2Component | null = null; + vaultItemsComponent: VaultItemsV2Component | null = null; @ViewChild(VaultFilterComponent, { static: true }) vaultFilterComponent: VaultFilterComponent | null = null; @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) @@ -407,14 +413,14 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { this.messagingService.send("minimizeOnCopy"); } - async viewCipher(cipher: CipherView) { - if (cipher.decryptionFailure) { + async viewCipher(c: CipherViewLike) { + if (CipherViewLikeUtils.decryptionFailure(c)) { DecryptionFailureDialogComponent.open(this.dialogService, { - cipherIds: [cipher.id as CipherId], + cipherIds: [c.id as CipherId], }); return; } - + const cipher = await this.cipherService.getFullCipherView(c); if (await this.shouldReprompt(cipher, "view")) { return; } @@ -472,7 +478,8 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { } } - viewCipherMenu(cipher: CipherView) { + async viewCipherMenu(c: CipherViewLike) { + const cipher = await this.cipherService.getFullCipherView(c); const menu: RendererMenuItem[] = [ { label: this.i18nService.t("view"), diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 197fc0ada0f..47ad93d81e2 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -536,7 +536,7 @@ export class VaultComponent implements OnInit, OnDestroy { const filterFunction = createFilterFunction(filter); if (await this.searchService.isSearchable(this.userId, searchText)) { - return await this.searchService.searchCiphers( + return await this.searchService.searchCiphers( this.userId, searchText, [filterFunction], @@ -772,7 +772,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - async onVaultItemsEvent(event: VaultItemEvent) { + async onVaultItemsEvent(event: VaultItemEvent) { this.processingEvent = true; try { diff --git a/apps/web/src/app/auth/settings/security/device-management.component.html b/apps/web/src/app/auth/settings/security/device-management-old.component.html similarity index 100% rename from apps/web/src/app/auth/settings/security/device-management.component.html rename to apps/web/src/app/auth/settings/security/device-management-old.component.html 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-old.component.spec.ts similarity index 95% rename from apps/web/src/app/auth/settings/security/device-management.component.spec.ts rename to apps/web/src/app/auth/settings/security/device-management-old.component.spec.ts index 2821d4a6d76..64fb9003ccf 100644 --- a/apps/web/src/app/auth/settings/security/device-management.component.spec.ts +++ b/apps/web/src/app/auth/settings/security/device-management-old.component.spec.ts @@ -20,7 +20,7 @@ import { import { SharedModule } from "../../../shared"; import { VaultBannersService } from "../../../vault/individual-vault/vault-banners/services/vault-banners.service"; -import { DeviceManagementComponent } from "./device-management.component"; +import { DeviceManagementOldComponent } from "./device-management-old.component"; class MockResizeObserver { observe = jest.fn(); @@ -35,8 +35,8 @@ interface Message { notificationId?: string; } -describe("DeviceManagementComponent", () => { - let fixture: ComponentFixture; +describe("DeviceManagementOldComponent", () => { + let fixture: ComponentFixture; let messageSubject: Subject; let mockDevices: DeviceView[]; let vaultBannersService: VaultBannersService; @@ -66,7 +66,7 @@ describe("DeviceManagementComponent", () => { SharedModule, TableModule, PopoverModule, - DeviceManagementComponent, + DeviceManagementOldComponent, ], providers: [ { @@ -130,7 +130,7 @@ describe("DeviceManagementComponent", () => { ], }).compileComponents(); - fixture = TestBed.createComponent(DeviceManagementComponent); + fixture = TestBed.createComponent(DeviceManagementOldComponent); vaultBannersService = TestBed.inject(VaultBannersService); }); diff --git a/apps/web/src/app/auth/settings/security/device-management.component.ts b/apps/web/src/app/auth/settings/security/device-management-old.component.ts similarity index 99% rename from apps/web/src/app/auth/settings/security/device-management.component.ts rename to apps/web/src/app/auth/settings/security/device-management-old.component.ts index 854a13faa99..556ba381acc 100644 --- a/apps/web/src/app/auth/settings/security/device-management.component.ts +++ b/apps/web/src/app/auth/settings/security/device-management-old.component.ts @@ -45,10 +45,10 @@ interface DeviceTableData { */ @Component({ selector: "app-device-management", - templateUrl: "./device-management.component.html", + templateUrl: "./device-management-old.component.html", imports: [CommonModule, SharedModule, TableModule, PopoverModule], }) -export class DeviceManagementComponent { +export class DeviceManagementOldComponent { protected dataSource = new TableDataSource(); protected currentDevice: DeviceView | undefined; protected loading = true; diff --git a/apps/web/src/app/auth/settings/security/security-routing.module.ts b/apps/web/src/app/auth/settings/security/security-routing.module.ts index 14d4aab8a36..2ec1be5cb7f 100644 --- a/apps/web/src/app/auth/settings/security/security-routing.module.ts +++ b/apps/web/src/app/auth/settings/security/security-routing.module.ts @@ -1,13 +1,15 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ChangePasswordComponent } from "../change-password.component"; import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component"; -import { DeviceManagementComponent } from "./device-management.component"; +import { DeviceManagementOldComponent } from "./device-management-old.component"; import { PasswordSettingsComponent } from "./password-settings/password-settings.component"; import { SecurityKeysComponent } from "./security-keys.component"; import { SecurityComponent } from "./security.component"; @@ -55,11 +57,15 @@ const routes: Routes = [ component: SecurityKeysComponent, data: { titleId: "keys" }, }, - { - path: "device-management", - component: DeviceManagementComponent, - data: { titleId: "devices" }, - }, + ...featureFlaggedRoute({ + defaultComponent: DeviceManagementOldComponent, + flaggedComponent: DeviceManagementComponent, + featureFlag: FeatureFlag.PM14938_BrowserExtensionLoginApproval, + routeOptions: { + path: "device-management", + data: { titleId: "devices" }, + }, + }), ], }, ]; diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 9cfe3117d40..d98a2ee8cf2 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -10,6 +10,8 @@ import { OrganizationUserApiService, CollectionService, } from "@bitwarden/admin-console/common"; +import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth/device-management/default-device-management-component.service"; +import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password"; import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; @@ -406,6 +408,11 @@ const safeProviders: SafeProvider[] = [ RouterService, ], }), + safeProvider({ + provide: DeviceManagementComponentServiceAbstraction, + useClass: DefaultDeviceManagementComponentService, + deps: [], + }), ]; @NgModule({ diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts index 8048d0dcefa..d6a330b55d7 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts @@ -16,8 +16,7 @@ export default { component: ReportCardComponent, decorators: [ moduleMetadata({ - imports: [JslibModule, BadgeModule, IconModule, RouterTestingModule], - declarations: [PremiumBadgeComponent], + imports: [JslibModule, BadgeModule, IconModule, RouterTestingModule, PremiumBadgeComponent], }), applicationConfig({ providers: [importProvidersFrom(PreloadedEnglishI18nModule)], diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts index 196c0994cd9..13523174913 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts @@ -18,8 +18,8 @@ export default { component: ReportListComponent, decorators: [ moduleMetadata({ - imports: [JslibModule, BadgeModule, RouterTestingModule, IconModule], - declarations: [PremiumBadgeComponent, ReportCardComponent], + imports: [JslibModule, BadgeModule, RouterTestingModule, IconModule, PremiumBadgeComponent], + declarations: [ReportCardComponent], }), applicationConfig({ providers: [importProvidersFrom(PreloadedEnglishI18nModule)], diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index d3e7fc495ca..8a2270113a9 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -610,7 +610,7 @@ const routes: Routes = [ data: { hideCardWrapper: true, hideIcon: true, - maxWidth: "3xl", + maxWidth: "4xl", } satisfies AnonLayoutWrapperData, children: [ { diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 63e54c46a8f..97c3fa0375c 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -42,10 +42,8 @@ import { WeakPasswordsReportComponent as OrgWeakPasswordsReportComponent } from import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import { HeaderModule } from "../layouts/header/header.module"; import { PremiumBadgeComponent } from "../vault/components/premium-badge.component"; -import { FolderAddEditComponent } from "../vault/individual-vault/folder-add-edit.component"; import { OrganizationBadgeModule } from "../vault/individual-vault/organization-badge/organization-badge.module"; import { PipesModule } from "../vault/individual-vault/pipes/pipes.module"; -import { PurgeVaultComponent } from "../vault/settings/purge-vault.component"; import { AccountFingerprintComponent } from "./components/account-fingerprint/account-fingerprint.component"; import { SharedModule } from "./shared.module"; @@ -68,6 +66,7 @@ import { SharedModule } from "./shared.module"; OrganizationLayoutComponent, VerifyRecoverDeleteOrgComponent, VaultTimeoutInputComponent, + PremiumBadgeComponent, ], declarations: [ AcceptFamilySponsorshipComponent, @@ -76,7 +75,6 @@ import { SharedModule } from "./shared.module"; EmergencyAccessConfirmComponent, EmergencyAccessTakeoverComponent, EmergencyAccessViewComponent, - FolderAddEditComponent, OrgEventsComponent, OrgExposedPasswordsReportComponent, OrgInactiveTwoFactorReportComponent, @@ -84,8 +82,6 @@ import { SharedModule } from "./shared.module"; OrgUnsecuredWebsitesReportComponent, OrgUserConfirmComponent, OrgWeakPasswordsReportComponent, - PremiumBadgeComponent, - PurgeVaultComponent, RecoverDeleteComponent, RecoverTwoFactorComponent, RemovePasswordComponent, @@ -106,7 +102,6 @@ import { SharedModule } from "./shared.module"; EmergencyAccessConfirmComponent, EmergencyAccessTakeoverComponent, EmergencyAccessViewComponent, - FolderAddEditComponent, OrganizationLayoutComponent, OrgEventsComponent, OrgExposedPasswordsReportComponent, @@ -116,7 +111,6 @@ import { SharedModule } from "./shared.module"; OrgUserConfirmComponent, OrgWeakPasswordsReportComponent, PremiumBadgeComponent, - PurgeVaultComponent, RecoverDeleteComponent, RecoverTwoFactorComponent, RemovePasswordComponent, diff --git a/apps/web/src/app/vault/components/premium-badge.component.ts b/apps/web/src/app/vault/components/premium-badge.component.ts index ec444404aea..4f1d1142f91 100644 --- a/apps/web/src/app/vault/components/premium-badge.component.ts +++ b/apps/web/src/app/vault/components/premium-badge.component.ts @@ -1,6 +1,8 @@ import { Component } from "@angular/core"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { BadgeModule } from "@bitwarden/components"; @Component({ selector: "app-premium-badge", @@ -9,7 +11,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag {{ "premium" | i18n }} `, - standalone: false, + imports: [JslibModule, BadgeModule], }) export class PremiumBadgeComponent { constructor(private messagingService: MessagingService) {} diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html index 3764f7d828f..cd091e11940 100644 --- a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html @@ -46,10 +46,10 @@ The first video is relatively positioned to force the layout and spacing of the videos. --> - + - + + - + { HTMLMediaElement.prototype.play = play; beforeEach(async () => { - window.matchMedia = jest.fn().mockReturnValue(false); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation(() => ({ + matches: false, + addListener() {}, + removeListener() {}, + })), + }); play.mockClear(); await TestBed.configureTestingModule({ @@ -126,45 +133,34 @@ describe("AddExtensionVideosComponent", () => { thirdVideo = component["videoElements"].get(2)!.nativeElement; }); - it("starts the video sequence when all videos are loaded", fakeAsync(() => { - tick(); - + it("starts the video sequence when all videos are loaded", () => { expect(firstVideo.play).toHaveBeenCalled(); - })); - - it("plays videos in sequence", fakeAsync(() => { - tick(); // let first video play + }); + it("plays videos in sequence", () => { play.mockClear(); firstVideo.onended!(new Event("ended")); // trigger next video - tick(); - expect(secondVideo.play).toHaveBeenCalledTimes(1); play.mockClear(); secondVideo.onended!(new Event("ended")); // trigger next video - tick(); - expect(thirdVideo.play).toHaveBeenCalledTimes(1); - })); + }); - it("doesn't play videos again when the user prefers no motion", fakeAsync(() => { + it("doesn't play videos again when the user prefers no motion", () => { component["prefersReducedMotion"] = true; - tick(); firstVideo.onended!(new Event("ended")); - tick(); + secondVideo.onended!(new Event("ended")); - tick(); play.mockClear(); thirdVideo.onended!(new Event("ended")); // trigger first video again - tick(); expect(play).toHaveBeenCalledTimes(0); - })); + }); }); }); diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts index 2420414fc88..d053e05c36b 100644 --- a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts @@ -17,6 +17,11 @@ export class AddExtensionVideosComponent { private document = inject(DOCUMENT); + /** CSS variable name tied to the video overlay */ + private cssOverlayVariable = "--overlay-opacity"; + /** CSS variable name tied to the video border */ + private cssBorderVariable = "--border-opacity"; + /** Current viewport size */ protected variant: "mobile" | "desktop" = "desktop"; @@ -26,6 +31,15 @@ export class AddExtensionVideosComponent { /** True when the user prefers reduced motion */ protected prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + /** CSS classes for the video container, pulled into the class only for readability. */ + protected videoContainerClass = [ + "tw-absolute tw-left-0 tw-top-0 tw-w-[15rem] tw-opacity-0 md:tw-opacity-100 md:tw-relative lg:tw-w-[17rem] tw-max-w-full tw-aspect-[0.807]", + `[${this.cssOverlayVariable}:0.7] after:tw-absolute after:tw-top-0 after:tw-left-0 after:tw-size-full after:tw-bg-primary-100 after:tw-content-[''] after:tw-rounded-lg after:tw-opacity-[--overlay-opacity]`, + `[${this.cssBorderVariable}:0] before:tw-absolute before:tw-top-0 before:tw-left-0 before:tw-w-full before:tw-h-2 before:tw-bg-primary-600 before:tw-content-[''] before:tw-rounded-t-lg before:tw-opacity-[--border-opacity]`, + "after:tw-transition-opacity after:tw-duration-400 after:tw-ease-linear", + "before:tw-transition-opacity before:tw-duration-400 before:tw-ease-linear", + ].join(" "); + /** Returns true when all videos are loaded */ get allVideosLoaded(): boolean { return this.numberOfLoadedVideos >= 3; @@ -97,12 +111,14 @@ export class AddExtensionVideosComponent { const video = this.videoElements.toArray()[index].nativeElement; video.onended = () => { void this.startVideoSequence(index + 1); + void this.addPausedStyles(video); }; this.mobileTransitionIn(index); - // Set muted via JavaScript, browsers are respecting autoplay consistently over just the HTML attribute + // Browsers are not respecting autoplay consistently with just the HTML attribute, set via JavaScript as well. video.muted = true; + this.addPlayingStyles(video); await video.play(); } @@ -143,4 +159,36 @@ export class AddExtensionVideosComponent { element.style.transition = transition ? "opacity 0.5s linear" : ""; element.style.opacity = "1"; } + + /** + * Add styles to the video that is moving to the paused/completed state. + * Fade in the overlay and fade out the border. + */ + private addPausedStyles(video: HTMLVideoElement): void { + const parentElement = video.parentElement; + if (!parentElement) { + return; + } + + // The border opacity transitions from 1 to 0 based on the percent complete. + parentElement.style.setProperty(this.cssBorderVariable, "0"); + // The opacity transitions from 0 to 0.7 based on the percent complete. + parentElement.style.setProperty(this.cssOverlayVariable, "0.7"); + } + + /** + * Add styles to the video that is moving to the playing state. + * Fade out the overlay and fade in the border. + */ + private addPlayingStyles(video: HTMLVideoElement): void { + const parentElement = video.parentElement; + if (!parentElement) { + return; + } + + // The border opacity transitions from 0 to 1 based on the percent complete. + parentElement.style.setProperty(this.cssBorderVariable, "1"); + // The opacity transitions from 0.7 to 0 based on the percent complete. + parentElement.style.setProperty(this.cssOverlayVariable, "0"); + } } diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index d79c1c4a8b4..804b533c2de 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -28,6 +28,7 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { DIALOG_DATA, DialogRef, @@ -91,7 +92,7 @@ export interface VaultItemDialogParams { /** * Function to restore a cipher from the trash. */ - restore?: (c: CipherView) => Promise; + restore?: (c: CipherViewLike) => Promise; } export const VaultItemDialogResult = { diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 227108ec25d..20b87bfc036 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -4,7 +4,7 @@ type="checkbox" bitCheckbox appStopProp - [disabled]="disabled || cipher.decryptionFailure" + [disabled]="disabled || decryptionFailure" [checked]="checked" (change)="$event ? this.checkedToggled.next() : null" [attr.aria-label]="'vaultItemSelect' | i18n" @@ -30,7 +30,7 @@ > {{ cipher.name }} - + - {{ cipher.subTitle }} + {{ subtitle }} - {{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }} + {{ (isDeleted ? "permanentlyDelete" : "delete") | i18n }} - + {{ "copyUsername" | i18n }} - + {{ "copyPassword" | i18n }} @@ -119,9 +119,9 @@ @@ -151,19 +151,14 @@ {{ "eventLogs" | i18n }} - + {{ "restore" | i18n }} - {{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }} + {{ (isDeleted ? "permanentlyDelete" : "delete") | i18n }} diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index 6078324a059..cb4d8ad70b1 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -6,7 +6,10 @@ import { CollectionView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { convertToPermission, @@ -20,11 +23,11 @@ import { RowHeightClass } from "./vault-items.component"; templateUrl: "vault-cipher-row.component.html", standalone: false, }) -export class VaultCipherRowComponent implements OnInit { +export class VaultCipherRowComponent implements OnInit { protected RowHeightClass = RowHeightClass; @Input() disabled: boolean; - @Input() cipher: CipherView; + @Input() cipher: C; @Input() showOwner: boolean; @Input() showCollections: boolean; @Input() showGroups: boolean; @@ -46,7 +49,7 @@ export class VaultCipherRowComponent implements OnInit { */ @Input() canRestoreCipher: boolean; - @Output() onEvent = new EventEmitter(); + @Output() onEvent = new EventEmitter>(); @Input() checked: boolean; @Output() checkedToggled = new EventEmitter(); @@ -74,33 +77,63 @@ export class VaultCipherRowComponent implements OnInit { } protected get clickAction() { - if (this.cipher.decryptionFailure) { + if (this.decryptionFailure) { return "showFailedToDecrypt"; } + return "view"; } protected get showTotpCopyButton() { - return ( - (this.cipher.login?.hasTotp ?? false) && - (this.cipher.organizationUseTotp || this.showPremiumFeatures) - ); + const login = CipherViewLikeUtils.getLogin(this.cipher); + + const hasTotp = login?.totp ?? false; + + return hasTotp && (this.cipher.organizationUseTotp || this.showPremiumFeatures); } protected get showFixOldAttachments() { return this.cipher.hasOldAttachments && this.cipher.organizationId == null; } + protected get hasAttachments() { + return CipherViewLikeUtils.hasAttachments(this.cipher); + } + protected get showAttachments() { - return this.canEditCipher || this.cipher.attachments?.length > 0; + return this.canEditCipher || this.hasAttachments; + } + + protected get canLaunch() { + return CipherViewLikeUtils.canLaunch(this.cipher); + } + + protected get launchUri() { + return CipherViewLikeUtils.getLaunchUri(this.cipher); + } + + protected get subtitle() { + return CipherViewLikeUtils.subtitle(this.cipher); + } + + protected get isDeleted() { + return CipherViewLikeUtils.isDeleted(this.cipher); + } + + protected get decryptionFailure() { + return CipherViewLikeUtils.decryptionFailure(this.cipher); } protected get showAssignToCollections() { - return this.organizations?.length && this.canAssignCollections && !this.cipher.isDeleted; + return ( + this.organizations?.length && + this.canAssignCollections && + !CipherViewLikeUtils.isDeleted(this.cipher) + ); } protected get showClone() { - return this.cloneable && !this.cipher.isDeleted; + return this.cloneable && !CipherViewLikeUtils.isDeleted(this.cipher); } protected get showEventLogs() { @@ -108,7 +141,18 @@ export class VaultCipherRowComponent implements OnInit { } protected get isNotDeletedLoginCipher() { - return this.cipher.type === this.CipherType.Login && !this.cipher.isDeleted; + return ( + CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login && + !CipherViewLikeUtils.isDeleted(this.cipher) + ); + } + + protected get hasPasswordToCopy() { + return CipherViewLikeUtils.hasCopyableValue(this.cipher, "password"); + } + + protected get hasUsernameToCopy() { + return CipherViewLikeUtils.hasCopyableValue(this.cipher, "username"); } protected get permissionText() { @@ -154,7 +198,7 @@ export class VaultCipherRowComponent implements OnInit { } protected get showLaunchUri(): boolean { - return this.isNotDeletedLoginCipher && this.cipher.login.canLaunch; + return this.isNotDeletedLoginCipher && this.canLaunch; } protected get disableMenu() { @@ -166,7 +210,7 @@ export class VaultCipherRowComponent implements OnInit { this.showAttachments || this.showClone || this.canEditCipher || - (this.cipher.isDeleted && this.canRestoreCipher) + (CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher) ); } diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts index 06c78ea0351..5d2b84aa10b 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts @@ -5,6 +5,7 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { CollectionAdminView, Unassigned, CollectionView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -20,7 +21,7 @@ import { RowHeightClass } from "./vault-items.component"; templateUrl: "vault-collection-row.component.html", standalone: false, }) -export class VaultCollectionRowComponent { +export class VaultCollectionRowComponent { protected RowHeightClass = RowHeightClass; protected Unassigned = "unassigned"; @@ -36,7 +37,7 @@ export class VaultCollectionRowComponent { @Input() groups: GroupView[]; @Input() showPermissionsColumn: boolean; - @Output() onEvent = new EventEmitter(); + @Output() onEvent = new EventEmitter>(); @Input() checked: boolean; @Output() checkedToggled = new EventEmitter(); diff --git a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts index 272d1585d95..130f86697c7 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts @@ -1,17 +1,17 @@ import { CollectionView } from "@bitwarden/admin-console/common"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { VaultItem } from "./vault-item"; -export type VaultItemEvent = - | { type: "viewAttachments"; item: CipherView } +export type VaultItemEvent = + | { type: "viewAttachments"; item: C } | { type: "bulkEditCollectionAccess"; items: CollectionView[] } | { type: "viewCollectionAccess"; item: CollectionView; readonly: boolean } - | { type: "viewEvents"; item: CipherView } + | { type: "viewEvents"; item: C } | { type: "editCollection"; item: CollectionView; readonly: boolean } - | { type: "clone"; item: CipherView } - | { type: "restore"; items: CipherView[] } - | { type: "delete"; items: VaultItem[] } - | { type: "copyField"; item: CipherView; field: "username" | "password" | "totp" } - | { type: "moveToFolder"; items: CipherView[] } - | { type: "assignToCollections"; items: CipherView[] }; + | { type: "clone"; item: C } + | { type: "restore"; items: C[] } + | { type: "delete"; items: VaultItem[] } + | { type: "copyField"; item: C; field: "username" | "password" | "totp" } + | { type: "moveToFolder"; items: C[] } + | { type: "assignToCollections"; items: C[] }; diff --git a/apps/web/src/app/vault/components/vault-items/vault-item.ts b/apps/web/src/app/vault/components/vault-items/vault-item.ts index 6ac198392ad..bccb84fb0bf 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-item.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-item.ts @@ -1,7 +1,7 @@ import { CollectionView } from "@bitwarden/admin-console/common"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; -export interface VaultItem { +export interface VaultItem { collection?: CollectionView; - cipher?: CipherView; + cipher?: C; } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 18dfa73ac5a..e82b03a8815 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -6,8 +6,11 @@ import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs"; import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { SortDirection, TableDataSource } from "@bitwarden/components"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -32,7 +35,7 @@ type ItemPermission = CollectionPermission | "NoAccess"; templateUrl: "vault-items.component.html", standalone: false, }) -export class VaultItemsComponent { +export class VaultItemsComponent { protected RowHeight = RowHeight; @Input() disabled: boolean; @@ -56,11 +59,11 @@ export class VaultItemsComponent { @Input() addAccessToggle: boolean; @Input() activeCollection: CollectionView | undefined; - private _ciphers?: CipherView[] = []; - @Input() get ciphers(): CipherView[] { + private _ciphers?: C[] = []; + @Input() get ciphers(): C[] { return this._ciphers; } - set ciphers(value: CipherView[] | undefined) { + set ciphers(value: C[] | undefined) { this._ciphers = value ?? []; this.refreshItems(); } @@ -74,11 +77,11 @@ export class VaultItemsComponent { this.refreshItems(); } - @Output() onEvent = new EventEmitter(); + @Output() onEvent = new EventEmitter>(); - protected editableItems: VaultItem[] = []; - protected dataSource = new TableDataSource(); - protected selection = new SelectionModel(true, [], true); + protected editableItems: VaultItem[] = []; + protected dataSource = new TableDataSource>(); + protected selection = new SelectionModel>(true, [], true); protected canDeleteSelected$: Observable; protected canRestoreSelected$: Observable; protected disableMenu$: Observable; @@ -233,7 +236,7 @@ export class VaultItemsComponent { : this.selection.select(...this.editableItems.slice(0, MaxSelectionCount)); } - protected event(event: VaultItemEvent) { + protected event(event: VaultItemEvent) { this.onEvent.emit(event); } @@ -263,7 +266,7 @@ export class VaultItemsComponent { } // TODO: PM-13944 Refactor to use cipherAuthorizationService.canClone$ instead - protected canClone(vaultItem: VaultItem) { + protected canClone(vaultItem: VaultItem) { if (vaultItem.cipher.organizationId == null) { return true; } @@ -287,7 +290,7 @@ export class VaultItemsComponent { return false; } - protected canEditCipher(cipher: CipherView) { + protected canEditCipher(cipher: C) { if (cipher.organizationId == null) { return true; } @@ -296,17 +299,17 @@ export class VaultItemsComponent { return (organization.canEditAllCiphers && this.viewingOrgVault) || cipher.edit; } - protected canAssignCollections(cipher: CipherView) { + protected canAssignCollections(cipher: C) { const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId); const editableCollections = this.allCollections.filter((c) => !c.readOnly); return ( (organization?.canEditAllCiphers && this.viewingOrgVault) || - (cipher.canAssignToCollections && editableCollections.length > 0) + (CipherViewLikeUtils.canAssignToCollections(cipher) && editableCollections.length > 0) ); } - protected canManageCollection(cipher: CipherView) { + protected canManageCollection(cipher: C) { // If the cipher is not part of an organization (personal item), user can manage it if (cipher.organizationId == null) { return true; @@ -338,9 +341,11 @@ export class VaultItemsComponent { } private refreshItems() { - const collections: VaultItem[] = this.collections.map((collection) => ({ collection })); - const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher })); - const items: VaultItem[] = [].concat(collections).concat(ciphers); + const collections: VaultItem[] = this.collections.map((collection) => ({ collection })); + const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ + cipher, + })); + const items: VaultItem[] = [].concat(collections).concat(ciphers); // All ciphers are selectable, collections only if they can be edited or deleted this.editableItems = items.filter( @@ -419,7 +424,7 @@ export class VaultItemsComponent { /** * Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name. */ - protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => { // Collections before ciphers const collectionCompare = this.prioritizeCollections(a, b, direction); if (collectionCompare !== 0) { @@ -432,7 +437,7 @@ export class VaultItemsComponent { /** * Sorts VaultItems based on group names */ - protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => { if ( !(a.collection instanceof CollectionAdminView) && !(b.collection instanceof CollectionAdminView) @@ -473,8 +478,8 @@ export class VaultItemsComponent { * Sorts VaultItems based on their permissions, with higher permissions taking precedence. * If permissions are equal, it falls back to sorting by name. */ - protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => { - const getPermissionPriority = (item: VaultItem): number => { + protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + const getPermissionPriority = (item: VaultItem): number => { const permission = item.collection ? this.getCollectionPermission(item.collection) : this.getCipherPermission(item.cipher); @@ -508,8 +513,8 @@ export class VaultItemsComponent { return this.compareNames(a, b); }; - private compareNames(a: VaultItem, b: VaultItem): number { - const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name; + private compareNames(a: VaultItem, b: VaultItem): number { + const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name; return getName(a)?.localeCompare(getName(b)) ?? -1; } @@ -517,7 +522,11 @@ export class VaultItemsComponent { * Sorts VaultItems by prioritizing collections over ciphers. * Collections are always placed before ciphers, regardless of the sorting direction. */ - private prioritizeCollections(a: VaultItem, b: VaultItem, direction: SortDirection): number { + private prioritizeCollections( + a: VaultItem, + b: VaultItem, + direction: SortDirection, + ): number { if (a.collection && !b.collection) { return direction === "asc" ? -1 : 1; } @@ -561,7 +570,7 @@ export class VaultItemsComponent { return "NoAccess"; } - private getCipherPermission(cipher: CipherView): ItemPermission { + private getCipherPermission(cipher: C): ItemPermission { if (!cipher.organizationId || cipher.collectionIds.length === 0) { return CollectionPermission.Manage; } 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 e65d423a57b..785c07fb634 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 @@ -36,6 +36,7 @@ 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/common/vault/services/restricted-item-types.service"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { LayoutComponent } from "@bitwarden/components"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -158,7 +159,7 @@ export default { argTypes: { onEvent: { action: "onEvent" } }, } as Meta; -type Story = StoryObj; +type Story = StoryObj>; export const Individual: Story = { args: { diff --git a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts index afb32738901..085a3d0d4b0 100644 --- a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts @@ -70,8 +70,10 @@ describe("WebVaultGeneratorDialogComponent", () => { generator.valueGenerated.emit("test-password"); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); - expect(button.attributes["aria-disabled"]).toBe(undefined); + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(false); }); it("should disable the button if no value has been generated", () => { @@ -82,8 +84,10 @@ describe("WebVaultGeneratorDialogComponent", () => { generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); - expect(button.attributes["aria-disabled"]).toBe("true"); + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(true); }); it("should disable the button if no algorithm is selected", () => { @@ -94,8 +98,10 @@ describe("WebVaultGeneratorDialogComponent", () => { generator.valueGenerated.emit("test-password"); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); - expect(button.attributes["aria-disabled"]).toBe("true"); + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(true); }); it("should close with selected value when confirmed", () => { diff --git a/apps/web/src/app/vault/individual-vault/folder-add-edit.component.html b/apps/web/src/app/vault/individual-vault/folder-add-edit.component.html deleted file mode 100644 index 556672534ea..00000000000 --- a/apps/web/src/app/vault/individual-vault/folder-add-edit.component.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - {{ title }} - - - - {{ "name" | i18n }} - - - - - - {{ "save" | i18n }} - - - {{ "cancel" | i18n }} - - - - - - - diff --git a/apps/web/src/app/vault/individual-vault/folder-add-edit.component.ts b/apps/web/src/app/vault/individual-vault/folder-add-edit.component.ts deleted file mode 100644 index 15c3e18544c..00000000000 --- a/apps/web/src/app/vault/individual-vault/folder-add-edit.component.ts +++ /dev/null @@ -1,139 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Inject } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; -import { firstValueFrom } from "rxjs"; - -import { FolderAddEditComponent as BaseFolderAddEditComponent } from "@bitwarden/angular/vault/components/folder-add-edit.component"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -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 { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; -import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; -import { - DIALOG_DATA, - DialogConfig, - DialogRef, - DialogService, - ToastService, -} from "@bitwarden/components"; -import { KeyService } from "@bitwarden/key-management"; - -@Component({ - selector: "app-folder-add-edit", - templateUrl: "folder-add-edit.component.html", - standalone: false, -}) -export class FolderAddEditComponent extends BaseFolderAddEditComponent { - protected override componentName = "app-folder-add-edit"; - constructor( - folderService: FolderService, - folderApiService: FolderApiServiceAbstraction, - protected accountSerivce: AccountService, - protected keyService: KeyService, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - logService: LogService, - dialogService: DialogService, - formBuilder: FormBuilder, - protected toastService: ToastService, - protected dialogRef: DialogRef, - @Inject(DIALOG_DATA) params: FolderAddEditDialogParams, - ) { - super( - folderService, - folderApiService, - accountSerivce, - keyService, - i18nService, - platformUtilsService, - logService, - dialogService, - formBuilder, - toastService, - ); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - params?.folderId ? (this.folderId = params.folderId) : null; - } - - deleteAndClose = async () => { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "deleteFolder" }, - content: { key: "deleteFolderConfirmation" }, - type: "warning", - }); - - if (!confirmed) { - return; - } - - try { - await this.folderApiService.delete(this.folder.id, await firstValueFrom(this.activeUserId$)); - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("deletedFolder"), - }); - } catch (e) { - this.logService.error(e); - } - - this.dialogRef.close(FolderAddEditDialogResult.Deleted); - }; - - submitAndClose = async () => { - this.folder.name = this.formGroup.controls.name.value; - if (this.folder.name == null || this.folder.name === "") { - this.formGroup.controls.name.markAsTouched(); - return; - } - - try { - const activeAccountId = await firstValueFrom(this.activeUserId$); - const userKey = await this.keyService.getUserKeyWithLegacySupport(activeAccountId); - const folder = await this.folderService.encrypt(this.folder, userKey); - this.formPromise = this.folderApiService.save(folder, activeAccountId); - await this.formPromise; - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t(this.editMode ? "editedFolder" : "addedFolder"), - }); - this.onSavedFolder.emit(this.folder); - this.dialogRef.close(FolderAddEditDialogResult.Saved); - } catch (e) { - this.logService.error(e); - } - return; - }; -} - -export interface FolderAddEditDialogParams { - folderId: string; -} - -export const FolderAddEditDialogResult = { - Deleted: "deleted", - Canceled: "canceled", - Saved: "saved", -} as const; - -export type FolderAddEditDialogResult = UnionOfValues; - -/** - * Strongly typed helper to open a FolderAddEdit dialog - * @param dialogService Instance of the dialog service that will be used to open the dialog - * @param config Optional configuration for the dialog - */ -export function openFolderAddEditDialog( - dialogService: DialogService, - config?: DialogConfig, -) { - return dialogService.open( - FolderAddEditComponent, - config, - ); -} diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts index 2154ecff1b7..93189f2bf1c 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts @@ -85,7 +85,7 @@ describe("vault filter service", () => { policyService.policyAppliesToUser$ .calledWith(PolicyType.SingleOrg, mockUserId) .mockReturnValue(singleOrgPolicy); - cipherService.cipherViews$.mockReturnValue(cipherViews); + cipherService.cipherListViews$.mockReturnValue(cipherViews); vaultFilterService = new VaultFilterService( organizationService, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts index f326034e806..1fe618c6c4e 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts @@ -38,6 +38,7 @@ 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 { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state"; +import { CipherListView } from "@bitwarden/sdk-internal"; import { CipherTypeFilter, @@ -85,7 +86,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { switchMap((userId) => combineLatest([ this.folderService.folderViews$(userId), - this.cipherService.cipherViews$(userId), + this.cipherService.cipherListViews$(userId), this._organizationFilter, ]), ), @@ -280,7 +281,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { protected async filterFolders( storedFolders: FolderView[], - ciphers: CipherView[], + ciphers: CipherView[] | CipherListView[], org?: Organization, ): Promise { // If no org or "My Vault" is selected, show all folders 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 3082d7cb809..00c540f6029 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 @@ -221,7 +221,7 @@ function createCipher(options: Partial = {}) { cipher.favorite = options.favorite ?? false; cipher.deletedDate = options.deletedDate; - cipher.type = options.type; + cipher.type = options.type ?? CipherType.Login; cipher.folderId = options.folderId; cipher.collectionIds = options.collectionIds; cipher.organizationId = options.organizationId; 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 a39918df4a7..1ed2e481fb8 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,40 +1,46 @@ 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 { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { All, RoutedVaultFilterModel } from "./routed-vault-filter.model"; -export type FilterFunction = (cipher: CipherView) => boolean; +export type FilterFunction = (cipher: CipherViewLike) => boolean; export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunction { return (cipher) => { + const type = CipherViewLikeUtils.getType(cipher); + const isDeleted = CipherViewLikeUtils.isDeleted(cipher); + if (filter.type === "favorites" && !cipher.favorite) { return false; } - if (filter.type === "card" && cipher.type !== CipherType.Card) { + if (filter.type === "card" && type !== CipherType.Card) { return false; } - if (filter.type === "identity" && cipher.type !== CipherType.Identity) { + if (filter.type === "identity" && type !== CipherType.Identity) { return false; } - if (filter.type === "login" && cipher.type !== CipherType.Login) { + if (filter.type === "login" && type !== CipherType.Login) { return false; } - if (filter.type === "note" && cipher.type !== CipherType.SecureNote) { + if (filter.type === "note" && type !== CipherType.SecureNote) { return false; } - if (filter.type === "sshKey" && cipher.type !== CipherType.SshKey) { + if (filter.type === "sshKey" && type !== CipherType.SshKey) { return false; } - if (filter.type === "trash" && !cipher.isDeleted) { + if (filter.type === "trash" && !isDeleted) { return false; } // Hide trash unless explicitly selected - if (filter.type !== "trash" && cipher.isDeleted) { + if (filter.type !== "trash" && isDeleted) { return false; } // No folder - if (filter.folderId === Unassigned && cipher.folderId !== null) { + if (filter.folderId === Unassigned && cipher.folderId != null) { return false; } // Folder diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts index b4eda51435f..8dc442abe2e 100644 --- a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts @@ -24,7 +24,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { LinkModule } from "@bitwarden/components"; import { OnboardingModule } from "../../../shared/components/onboarding/onboarding.module"; @@ -44,7 +44,7 @@ import { VaultOnboardingService, VaultOnboardingTasks } from "./services/vault-o templateUrl: "vault-onboarding.component.html", }) export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { - @Input() ciphers: CipherView[]; + @Input() ciphers: CipherViewLike[]; @Input() orgs: Organization[]; @Output() onAddCipher = new EventEmitter(); 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 380e0280b5a..c8c2f681bb4 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -67,8 +67,13 @@ 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 { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { DialogRef, DialogService, Icons, ToastService } from "@bitwarden/components"; +import { CipherListView } from "@bitwarden/sdk-internal"; import { AddEditFolderDialogComponent, AddEditFolderDialogResult, @@ -149,7 +154,7 @@ const SearchTextDebounceInterval = 200; DefaultCipherFormConfigService, ], }) -export class VaultComponent implements OnInit, OnDestroy { +export class VaultComponent implements OnInit, OnDestroy { @ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent; trashCleanupWarning: string = null; @@ -165,7 +170,7 @@ export class VaultComponent implements OnInit, OnDestroy { protected canAccessPremium: boolean; protected allCollections: CollectionView[]; protected allOrganizations: Organization[] = []; - protected ciphers: CipherView[]; + protected ciphers: C[]; protected collections: CollectionView[]; protected isEmpty: boolean; protected selectedCollection: TreeNode | undefined; @@ -350,11 +355,15 @@ export class VaultComponent implements OnInit, OnDestroy { this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); + const _ciphers = this.cipherService + .cipherListViews$(activeUserId) + .pipe(filter((c) => c !== null)); + /** * This observable filters the ciphers based on the active user ID and the restricted item types. */ const allowedCiphers$ = combineLatest([ - this.cipherService.cipherViews$(activeUserId).pipe(filter((c) => c !== null)), + _ciphers, this.restrictedItemTypesService.restricted$, ]).pipe( map(([ciphers, restrictedTypes]) => @@ -374,15 +383,15 @@ export class VaultComponent implements OnInit, OnDestroy { const allCiphers = [...failedCiphers, ...ciphers]; if (await this.searchService.isSearchable(activeUserId, searchText)) { - return await this.searchService.searchCiphers( + return await this.searchService.searchCiphers( activeUserId, searchText, [filterFunction], - allCiphers, + allCiphers as C[], ); } - return allCiphers.filter(filterFunction); + return ciphers.filter(filterFunction) as C[]; }), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -566,7 +575,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.vaultFilterService.clearOrganizationFilter(); } - async onVaultItemsEvent(event: VaultItemEvent) { + async onVaultItemsEvent(event: VaultItemEvent) { this.processingEvent = true; try { switch (event.type) { @@ -654,7 +663,7 @@ export class VaultComponent implements OnInit, OnDestroy { * @param cipher * @returns */ - async editCipherAttachments(cipher: CipherView) { + async editCipherAttachments(cipher: C) { if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { await this.go({ cipherId: null, itemId: null }); return; @@ -761,7 +770,7 @@ export class VaultComponent implements OnInit, OnDestroy { await this.openVaultItemDialog("form", cipherFormConfig); } - async editCipher(cipher: CipherView, cloneMode?: boolean) { + async editCipher(cipher: CipherView | CipherListView, cloneMode?: boolean) { return this.editCipherId(cipher?.id, cloneMode); } @@ -929,7 +938,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async bulkAssignToCollections(ciphers: CipherView[]) { + async bulkAssignToCollections(ciphers: C[]) { if (!(await this.repromptCipher(ciphers))) { return; } @@ -955,9 +964,28 @@ export class VaultComponent implements OnInit, OnDestroy { ); } + let ciphersToAssign: CipherView[]; + + // Convert `CipherListView` to `CipherView` if necessary + if (ciphers.some(CipherViewLikeUtils.isCipherListView)) { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + ciphersToAssign = await firstValueFrom( + this.cipherService + .cipherViews$(userId) + .pipe( + map( + (cipherViews) => + cipherViews.filter((c) => ciphers.some((cc) => cc.id === c.id)) as CipherView[], + ), + ), + ); + } else { + ciphersToAssign = ciphers as CipherView[]; + } + const dialog = AssignCollectionsWebComponent.open(this.dialogService, { data: { - ciphers, + ciphers: ciphersToAssign, organizationId: orgId as OrganizationId, availableCollections, activeCollection: this.activeFilter?.selectedCollectionNode?.node, @@ -970,8 +998,8 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async cloneCipher(cipher: CipherView) { - if (cipher.login?.hasFido2Credentials) { + async cloneCipher(cipher: CipherView | CipherListView) { + if (CipherViewLikeUtils.hasFido2Credentials(cipher)) { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "passkeyNotCopied" }, content: { key: "passkeyNotCopiedAlert" }, @@ -986,8 +1014,8 @@ export class VaultComponent implements OnInit, OnDestroy { await this.editCipher(cipher, true); } - restore = async (c: CipherView): Promise => { - if (!c.isDeleted) { + restore = async (c: C): Promise => { + if (!CipherViewLikeUtils.isDeleted(c)) { return; } @@ -1014,7 +1042,7 @@ export class VaultComponent implements OnInit, OnDestroy { } }; - async bulkRestore(ciphers: CipherView[]) { + async bulkRestore(ciphers: C[]) { if (ciphers.some((c) => !c.edit)) { this.showMissingPermissionsError(); return; @@ -1044,8 +1072,8 @@ export class VaultComponent implements OnInit, OnDestroy { this.refresh(); } - private async handleDeleteEvent(items: VaultItem[]) { - const ciphers = items.filter((i) => i.collection === undefined).map((i) => i.cipher); + private async handleDeleteEvent(items: VaultItem[]) { + const ciphers: C[] = items.filter((i) => i.collection === undefined).map((i) => i.cipher); const collections = items.filter((i) => i.cipher === undefined).map((i) => i.collection); if (ciphers.length === 1 && collections.length === 0) { await this.deleteCipher(ciphers[0]); @@ -1062,7 +1090,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async deleteCipher(c: CipherView): Promise { + async deleteCipher(c: C): Promise { if (!(await this.repromptCipher([c]))) { return; } @@ -1072,7 +1100,7 @@ export class VaultComponent implements OnInit, OnDestroy { return; } - const permanent = c.isDeleted; + const permanent = CipherViewLikeUtils.isDeleted(c); const confirmed = await this.dialogService.openSimpleDialog({ title: { key: permanent ? "permanentlyDeleteItem" : "deleteItem" }, @@ -1099,11 +1127,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async bulkDelete( - ciphers: CipherView[], - collections: CollectionView[], - organizations: Organization[], - ) { + async bulkDelete(ciphers: C[], collections: CollectionView[], organizations: Organization[]) { if (!(await this.repromptCipher(ciphers))) { return; } @@ -1142,7 +1166,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async bulkMove(ciphers: CipherView[]) { + async bulkMove(ciphers: C[]) { if (!(await this.repromptCipher(ciphers))) { return; } @@ -1167,22 +1191,32 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async copy(cipher: CipherView, field: "username" | "password" | "totp") { + async copy(cipher: C, field: "username" | "password" | "totp") { let aType; let value; let typeI18nKey; + const login = CipherViewLikeUtils.getLogin(cipher); + + if (!login) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("unexpectedError"), + }); + } + if (field === "username") { aType = "Username"; - value = cipher.login.username; + value = login.username; typeI18nKey = "username"; } else if (field === "password") { aType = "Password"; - value = cipher.login.password; + value = await this.getPasswordFromCipherViewLike(cipher); typeI18nKey = "password"; } else if (field === "totp") { aType = "TOTP"; - const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); + const totpResponse = await firstValueFrom(this.totpService.getCode$(login.totp)); value = totpResponse.code; typeI18nKey = "verificationCodeTotp"; } else { @@ -1228,7 +1262,7 @@ export class VaultComponent implements OnInit, OnDestroy { : this.cipherService.softDeleteWithServer(id, userId); } - protected async repromptCipher(ciphers: CipherView[]) { + protected async repromptCipher(ciphers: C[]) { const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); return notProtected || (await this.passwordRepromptService.showPasswordPrompt()); @@ -1264,6 +1298,21 @@ export class VaultComponent implements OnInit, OnDestroy { message: this.i18nService.t("missingPermissions"), }); } + + /** + * Returns the password for a `CipherViewLike` object. + * `CipherListView` does not contain the password, the full `CipherView` needs to be fetched. + */ + private async getPasswordFromCipherViewLike(cipher: C): Promise { + if (!CipherViewLikeUtils.isCipherListView(cipher)) { + return Promise.resolve(cipher.login?.password); + } + + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const _cipher = await this.cipherService.get(cipher.id, activeUserId); + const cipherView = await this.cipherService.decrypt(_cipher, activeUserId); + return cipherView.login?.password; + } } /** diff --git a/apps/web/src/app/vault/settings/purge-vault.component.ts b/apps/web/src/app/vault/settings/purge-vault.component.ts index 0a25122788c..4c58a27adb7 100644 --- a/apps/web/src/app/vault/settings/purge-vault.component.ts +++ b/apps/web/src/app/vault/settings/purge-vault.component.ts @@ -18,14 +18,16 @@ import { ToastService, } from "@bitwarden/components"; +import { UserVerificationModule } from "../../auth/shared/components/user-verification"; +import { SharedModule } from "../../shared"; + export interface PurgeVaultDialogData { organizationId: string; } @Component({ - selector: "app-purge-vault", templateUrl: "purge-vault.component.html", - standalone: false, + imports: [SharedModule, UserVerificationModule], }) export class PurgeVaultComponent { organizationId: string = null; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a58126adac5..4c4a97e6404 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -864,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3489,6 +3506,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3971,6 +3991,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4115,6 +4151,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/libs/angular/src/auth/device-management/default-device-management-component.service.ts b/libs/angular/src/auth/device-management/default-device-management-component.service.ts new file mode 100644 index 00000000000..5089ba259a5 --- /dev/null +++ b/libs/angular/src/auth/device-management/default-device-management-component.service.ts @@ -0,0 +1,15 @@ +import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction"; + +/** + * Default implementation of the device management component service + */ +export class DefaultDeviceManagementComponentService + implements DeviceManagementComponentServiceAbstraction +{ + /** + * Show header information in web client + */ + showHeaderInformation(): boolean { + return true; + } +} diff --git a/libs/angular/src/auth/device-management/device-management-component.service.abstraction.ts b/libs/angular/src/auth/device-management/device-management-component.service.abstraction.ts new file mode 100644 index 00000000000..02834908658 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-component.service.abstraction.ts @@ -0,0 +1,11 @@ +/** + * Service abstraction for device management component + * Used to determine client-specific behavior + */ +export abstract class DeviceManagementComponentServiceAbstraction { + /** + * Whether to show header information (title, description, etc.) in the device management component + * @returns true if header information should be shown, false otherwise + */ + abstract showHeaderInformation(): boolean; +} diff --git a/libs/angular/src/auth/device-management/device-management-item-group.component.html b/libs/angular/src/auth/device-management/device-management-item-group.component.html new file mode 100644 index 00000000000..b47408059a2 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-item-group.component.html @@ -0,0 +1,63 @@ + + + @if (device.pendingAuthRequest) { + + + {{ device.displayName }} + + + + + {{ "requestPending" | i18n }} + + + + + + {{ "needsApproval" | i18n }} + + {{ "firstLogin" | i18n }}: + {{ device.firstLogin | date: "medium" }} + + + + } @else { + + + {{ device.displayName }} + + + + + {{ "currentSession" | i18n }} + + + + + + @if (device.isTrusted) { + {{ "trusted" | i18n }} + } @else { + + } + + + {{ "firstLogin" | i18n }}: + {{ device.firstLogin | date: "medium" }} + + + + } + + diff --git a/libs/angular/src/auth/device-management/device-management-item-group.component.ts b/libs/angular/src/auth/device-management/device-management-item-group.component.ts new file mode 100644 index 00000000000..62468a18225 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-item-group.component.ts @@ -0,0 +1,44 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { LoginApprovalComponent } from "@bitwarden/auth/angular"; +import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response"; +import { BadgeModule, DialogService, ItemModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { DeviceDisplayData } from "./device-management.component"; +import { clearAuthRequestAndResortDevices } from "./resort-devices.helper"; + +/** Displays user devices in an item list view */ +@Component({ + standalone: true, + selector: "auth-device-management-item-group", + templateUrl: "./device-management-item-group.component.html", + imports: [BadgeModule, CommonModule, ItemModule, I18nPipe], +}) +export class DeviceManagementItemGroupComponent { + @Input() devices: DeviceDisplayData[] = []; + + constructor(private dialogService: DialogService) {} + + protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) { + if (pendingAuthRequest == null) { + return; + } + + const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, { + notificationId: pendingAuthRequest.id, + }); + + const result = await firstValueFrom(loginApprovalDialog.closed); + + if (result !== undefined && typeof result === "boolean") { + // Auth request was approved or denied, so clear the + // pending auth request and re-sort the device array + this.devices = clearAuthRequestAndResortDevices(this.devices, pendingAuthRequest); + } + } +} diff --git a/libs/angular/src/auth/device-management/device-management-table.component.html b/libs/angular/src/auth/device-management/device-management-table.component.html new file mode 100644 index 00000000000..febb0a96a4e --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-table.component.html @@ -0,0 +1,62 @@ + + + + + {{ column.title }} + + + + + + + + + + + + + @if (device.pendingAuthRequest) { + + {{ device.displayName }} + + + {{ "needsApproval" | i18n }} + + } @else { + {{ device.displayName }} + + {{ "trusted" | i18n }} + + } + + + + + + + + {{ "currentSession" | i18n }} + + + {{ "requestPending" | i18n }} + + + + + + {{ device.firstLogin | date: "medium" }} + + diff --git a/libs/angular/src/auth/device-management/device-management-table.component.ts b/libs/angular/src/auth/device-management/device-management-table.component.ts new file mode 100644 index 00000000000..1d20e54deec --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-table.component.ts @@ -0,0 +1,86 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +// 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 { LoginApprovalComponent } from "@bitwarden/auth/angular"; +import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + BadgeModule, + ButtonModule, + DialogService, + LinkModule, + TableDataSource, + TableModule, +} from "@bitwarden/components"; + +import { DeviceDisplayData } from "./device-management.component"; +import { clearAuthRequestAndResortDevices } from "./resort-devices.helper"; + +/** Displays user devices in a sortable table view */ +@Component({ + standalone: true, + selector: "auth-device-management-table", + templateUrl: "./device-management-table.component.html", + imports: [BadgeModule, ButtonModule, CommonModule, JslibModule, LinkModule, TableModule], +}) +export class DeviceManagementTableComponent implements OnChanges { + @Input() devices: DeviceDisplayData[] = []; + protected tableDataSource = new TableDataSource(); + + protected readonly columnConfig = [ + { + name: "displayName", + title: this.i18nService.t("device"), + headerClass: "tw-w-1/3", + sortable: true, + }, + { + name: "loginStatus", + title: this.i18nService.t("loginStatus"), + headerClass: "tw-w-1/3", + sortable: true, + }, + { + name: "firstLogin", + title: this.i18nService.t("firstLogin"), + headerClass: "tw-w-1/3", + sortable: true, + }, + ]; + + constructor( + private i18nService: I18nService, + private dialogService: DialogService, + ) {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes.devices) { + this.tableDataSource.data = this.devices; + } + } + + protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) { + if (pendingAuthRequest == null) { + return; + } + + const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, { + notificationId: pendingAuthRequest.id, + }); + + const result = await firstValueFrom(loginApprovalDialog.closed); + + if (result !== undefined && typeof result === "boolean") { + // Auth request was approved or denied, so clear the + // pending auth request and re-sort the device array + this.tableDataSource.data = clearAuthRequestAndResortDevices( + this.devices, + pendingAuthRequest, + ); + } + } +} diff --git a/libs/angular/src/auth/device-management/device-management.component.html b/libs/angular/src/auth/device-management/device-management.component.html new file mode 100644 index 00000000000..8b82140a508 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management.component.html @@ -0,0 +1,40 @@ + + + {{ "devices" | i18n }} + + + + + + + {{ "aDeviceIs" | i18n }} + + + + + {{ "deviceListDescriptionTemp" | i18n }} + + + +@if (initializing) { + + + +} @else { + + + + + +} diff --git a/libs/angular/src/auth/device-management/device-management.component.ts b/libs/angular/src/auth/device-management/device-management.component.ts new file mode 100644 index 00000000000..dc7700a9410 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management.component.ts @@ -0,0 +1,230 @@ +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { firstValueFrom } from "rxjs"; + +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common"; +import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; +import { + DevicePendingAuthRequest, + DeviceResponse, +} from "@bitwarden/common/auth/abstractions/devices/responses/device.response"; +import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view"; +import { DeviceType, DeviceTypeMetadata } 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 { ButtonModule, PopoverModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction"; +import { DeviceManagementItemGroupComponent } from "./device-management-item-group.component"; +import { DeviceManagementTableComponent } from "./device-management-table.component"; + +export interface DeviceDisplayData { + displayName: string; + firstLogin: Date; + icon: string; + id: string; + identifier: string; + isCurrentDevice: boolean; + isTrusted: boolean; + loginStatus: string; + pendingAuthRequest: DevicePendingAuthRequest | null; +} + +/** + * The `DeviceManagementComponent` fetches user devices and passes them down + * to a child component for display. + * + * The specific child component that gets displayed depends on the viewport width: + * - Medium to Large screens = `bit-table` view + * - Small screens = `bit-item-group` view + */ +@Component({ + standalone: true, + selector: "auth-device-management", + templateUrl: "./device-management.component.html", + imports: [ + ButtonModule, + CommonModule, + DeviceManagementItemGroupComponent, + DeviceManagementTableComponent, + I18nPipe, + PopoverModule, + ], +}) +export class DeviceManagementComponent implements OnInit { + protected devices: DeviceDisplayData[] = []; + protected initializing = true; + protected showHeaderInfo = false; + + constructor( + private authRequestApiService: AuthRequestApiServiceAbstraction, + private destroyRef: DestroyRef, + private deviceManagementComponentService: DeviceManagementComponentServiceAbstraction, + private devicesService: DevicesServiceAbstraction, + private i18nService: I18nService, + private messageListener: MessageListener, + private validationService: ValidationService, + ) { + this.showHeaderInfo = this.deviceManagementComponentService.showHeaderInformation(); + } + + async ngOnInit() { + await this.loadDevices(); + + this.messageListener.allMessages$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((message) => { + if ( + message.command === "openLoginApproval" && + message.notificationId && + typeof message.notificationId === "string" + ) { + void this.upsertDeviceWithPendingAuthRequest(message.notificationId); + } + }); + } + + async loadDevices() { + try { + const devices = await firstValueFrom(this.devicesService.getDevices$()); + const currentDevice = await firstValueFrom(this.devicesService.getCurrentDevice$()); + + if (!devices || !currentDevice) { + return; + } + + this.devices = this.mapDevicesToDisplayData(devices, currentDevice); + } catch (e) { + this.validationService.showError(e); + } finally { + this.initializing = false; + } + } + + private mapDevicesToDisplayData( + devices: DeviceView[], + currentDevice: DeviceResponse, + ): DeviceDisplayData[] { + return devices + .map((device): DeviceDisplayData | null => { + if (!device.id) { + this.validationService.showError(new Error(this.i18nService.t("deviceIdMissing"))); + return null; + } + + if (device.type == undefined) { + this.validationService.showError(new Error(this.i18nService.t("deviceTypeMissing"))); + return null; + } + + if (!device.creationDate) { + this.validationService.showError( + new Error(this.i18nService.t("deviceCreationDateMissing")), + ); + return null; + } + + return { + displayName: this.devicesService.getReadableDeviceTypeName(device.type), + firstLogin: device.creationDate ? new Date(device.creationDate) : new Date(), + icon: this.getDeviceIcon(device.type), + id: device.id || "", + identifier: device.identifier ?? "", + isCurrentDevice: this.isCurrentDevice(device, currentDevice), + isTrusted: device.response?.isTrusted ?? false, + loginStatus: this.getLoginStatus(device, currentDevice), + pendingAuthRequest: device.response?.devicePendingAuthRequest ?? null, + }; + }) + .filter((device) => device !== null); + } + + private async upsertDeviceWithPendingAuthRequest(authRequestId: string) { + const authRequestResponse = await this.authRequestApiService.getAuthRequest(authRequestId); + if (!authRequestResponse) { + return; + } + + const upsertDevice: DeviceDisplayData = { + displayName: this.devicesService.getReadableDeviceTypeName( + authRequestResponse.requestDeviceTypeValue, + ), + firstLogin: new Date(authRequestResponse.creationDate), + icon: this.getDeviceIcon(authRequestResponse.requestDeviceTypeValue), + id: "", + identifier: authRequestResponse.requestDeviceIdentifier, + isCurrentDevice: false, + isTrusted: false, + loginStatus: this.i18nService.t("requestPending"), + pendingAuthRequest: { + id: authRequestResponse.id, + creationDate: authRequestResponse.creationDate, + }, + }; + + // If the device already exists in the DB, update the device id and first login date + if (authRequestResponse.requestDeviceIdentifier) { + const existingDevice = await firstValueFrom( + this.devicesService.getDeviceByIdentifier$(authRequestResponse.requestDeviceIdentifier), + ); + + if (existingDevice?.id && existingDevice.creationDate) { + upsertDevice.id = existingDevice.id; + upsertDevice.firstLogin = new Date(existingDevice.creationDate); + } + } + + const existingDeviceIndex = this.devices.findIndex( + (device) => device.identifier === upsertDevice.identifier, + ); + + if (existingDeviceIndex >= 0) { + // Update existing device in device list + this.devices[existingDeviceIndex] = upsertDevice; + this.devices = [...this.devices]; + } else { + // Add new device to device list + this.devices = [upsertDevice, ...this.devices]; + } + } + + private getLoginStatus(device: DeviceView, currentDevice: DeviceResponse): string { + if (this.isCurrentDevice(device, currentDevice)) { + return this.i18nService.t("currentSession"); + } + + if (this.hasPendingAuthRequest(device)) { + return this.i18nService.t("requestPending"); + } + + return ""; + } + + private isCurrentDevice(device: DeviceView, currentDevice: DeviceResponse): boolean { + return device.id === currentDevice.id; + } + + private hasPendingAuthRequest(device: DeviceView): boolean { + return device.response?.devicePendingAuthRequest != null; + } + + private getDeviceIcon(type: DeviceType): string { + const defaultIcon = "bwi bwi-desktop"; + const categoryIconMap: Record = { + webApp: "bwi bwi-browser", + desktop: "bwi bwi-desktop", + mobile: "bwi bwi-mobile", + cli: "bwi bwi-cli", + extension: "bwi bwi-puzzle", + sdk: "bwi bwi-desktop", + }; + + const metadata = DeviceTypeMetadata[type]; + return metadata ? (categoryIconMap[metadata.category] ?? defaultIcon) : defaultIcon; + } +} diff --git a/libs/angular/src/auth/device-management/resort-devices.helper.ts b/libs/angular/src/auth/device-management/resort-devices.helper.ts new file mode 100644 index 00000000000..e739e943ee8 --- /dev/null +++ b/libs/angular/src/auth/device-management/resort-devices.helper.ts @@ -0,0 +1,53 @@ +import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response"; + +import { DeviceDisplayData } from "./device-management.component"; + +export function clearAuthRequestAndResortDevices( + devices: DeviceDisplayData[], + pendingAuthRequest: DevicePendingAuthRequest, +): DeviceDisplayData[] { + return devices + .map((device) => { + if (device.pendingAuthRequest?.id === pendingAuthRequest.id) { + device.pendingAuthRequest = null; + device.loginStatus = ""; + } + return device; + }) + .sort(resortDevices); +} + +/** + * After a device is approved/denied, it will still be at the beginning of the array, + * so we must resort the array to ensure it is in the correct order. + * + * This is a helper function that gets passed to the `Array.sort()` method + */ +function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) { + // Devices with a pending auth request should be first + if (deviceA.pendingAuthRequest) { + return -1; + } + if (deviceB.pendingAuthRequest) { + return 1; + } + + // Next is the current device + if (deviceA.isCurrentDevice) { + return -1; + } + if (deviceB.isCurrentDevice) { + return 1; + } + + // Then sort the rest by display name (alphabetically) + if (deviceA.displayName < deviceB.displayName) { + return -1; + } + if (deviceA.displayName > deviceB.displayName) { + return 1; + } + + // Default + return 0; +} diff --git a/libs/angular/src/auth/guards/index.ts b/libs/angular/src/auth/guards/index.ts index 8a4d0be8167..a0aadd3a4d1 100644 --- a/libs/angular/src/auth/guards/index.ts +++ b/libs/angular/src/auth/guards/index.ts @@ -4,3 +4,4 @@ export * from "./lock.guard"; export * from "./redirect/redirect.guard"; export * from "./tde-decryption-required.guard"; export * from "./unauth.guard"; +export * from "./redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard"; diff --git a/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/README.md b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/README.md new file mode 100644 index 00000000000..c72ddc86e15 --- /dev/null +++ b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/README.md @@ -0,0 +1,19 @@ +# RedirectToVaultIfUnlocked Guard + +The `redirectToVaultIfUnlocked` redirects the user to `/vault` if they are `Unlocked`. Otherwise, it allows access to the route. + +This is particularly useful for routes that can handle BOTH unauthenticated AND authenticated-but-locked users (which makes the `authGuard` unusable on those routes). + + + +### Special Use Case - Authenticating in the Extension Popout + +Imagine a user is going through the Login with Device flow in the Extension pop*out*: + +- They open the pop*out* while on `/login-with-device` +- The approve the login from another device +- They are authenticated and routed to `/vault` while in the pop*out* + +If the `redirectToVaultIfUnlocked` were NOT applied, if this user now opens the pop*up* they would be shown the `/login-with-device`, not their `/vault`. + +But by adding the `redirectToVaultIfUnlocked` to `/login-with-device` we make sure to check if the user has already `Unlocked`, and if so, route them to `/vault` upon opening the pop*up*. diff --git a/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts new file mode 100644 index 00000000000..004499beede --- /dev/null +++ b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts @@ -0,0 +1,98 @@ +import { TestBed } from "@angular/core/testing"; +import { Router, provideRouter } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.guard"; + +describe("redirectToVaultIfUnlockedGuard", () => { + const activeUser: Account = { + id: "userId" as UserId, + email: "test@email.com", + emailVerified: true, + name: "Test User", + }; + + const setup = (activeUser: Account | null, authStatus: AuthenticationStatus | null) => { + const accountService = mock(); + const authService = mock(); + + accountService.activeAccount$ = new BehaviorSubject(activeUser); + authService.authStatusFor$.mockReturnValue(of(authStatus)); + + const testBed = TestBed.configureTestingModule({ + providers: [ + { provide: AccountService, useValue: accountService }, + { provide: AuthService, useValue: authService }, + provideRouter([ + { path: "", component: EmptyComponent }, + { path: "vault", component: EmptyComponent }, + { + path: "guarded-route", + component: EmptyComponent, + canActivate: [redirectToVaultIfUnlockedGuard()], + }, + ]), + ], + }); + + return { + router: testBed.inject(Router), + }; + }; + + it("should be created", () => { + const { router } = setup(null, null); + expect(router).toBeTruthy(); + }); + + it("should redirect to /vault if the user is AuthenticationStatus.Unlocked", async () => { + // Arrange + const { router } = setup(activeUser, AuthenticationStatus.Unlocked); + + // Act + await router.navigate(["guarded-route"]); + + // Assert + expect(router.url).toBe("/vault"); + }); + + it("should allow navigation to continue to the route if there is no active user", async () => { + // Arrange + const { router } = setup(null, null); + + // Act + await router.navigate(["guarded-route"]); + + // Assert + expect(router.url).toBe("/guarded-route"); + }); + + it("should allow navigation to continue to the route if the user is AuthenticationStatus.LoggedOut", async () => { + // Arrange + const { router } = setup(null, AuthenticationStatus.LoggedOut); + + // Act + await router.navigate(["guarded-route"]); + + // Assert + expect(router.url).toBe("/guarded-route"); + }); + + it("should allow navigation to continue to the route if the user is AuthenticationStatus.Locked", async () => { + // Arrange + const { router } = setup(null, AuthenticationStatus.Locked); + + // Act + await router.navigate(["guarded-route"]); + + // Assert + expect(router.url).toBe("/guarded-route"); + }); +}); diff --git a/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.ts b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.ts new file mode 100644 index 00000000000..c39bce06a45 --- /dev/null +++ b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.ts @@ -0,0 +1,36 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; + +/** + * Redirects the user to `/vault` if they are `Unlocked`. Otherwise, it allows access to the route. + * See ./redirect-to-vault-if-unlocked/README.md for more details. + */ +export function redirectToVaultIfUnlockedGuard(): CanActivateFn { + return async () => { + const accountService = inject(AccountService); + const authService = inject(AuthService); + const router = inject(Router); + + const activeUser = await firstValueFrom(accountService.activeAccount$); + + // If there is no active user, allow access to the route + if (!activeUser) { + return true; + } + + const authStatus = await firstValueFrom(authService.authStatusFor$(activeUser.id)); + + // If user is Unlocked, redirect to vault + if (authStatus === AuthenticationStatus.Unlocked) { + return router.createUrlTree(["/vault"]); + } + + // If user is LoggedOut or Locked, allow access to the route + return true; + }; +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 2d98ae7080d..dfb8d5c018e 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1191,7 +1191,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DevicesServiceAbstraction, useClass: DevicesServiceImplementation, - deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction], + deps: [AppIdServiceAbstraction, DevicesApiServiceAbstraction, I18nServiceAbstraction], }), safeProvider({ provide: AuthRequestApiServiceAbstraction, diff --git a/libs/angular/src/vault/components/icon.component.ts b/libs/angular/src/vault/components/icon.component.ts index fd178db23b6..0718b6fc76c 100644 --- a/libs/angular/src/vault/components/icon.component.ts +++ b/libs/angular/src/vault/components/icon.component.ts @@ -13,7 +13,7 @@ import { import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { buildCipherIcon, CipherIconDetails } from "@bitwarden/common/vault/icon/build-cipher-icon"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; @Component({ selector: "app-vault-icon", @@ -25,7 +25,7 @@ export class IconComponent { /** * The cipher to display the icon for. */ - cipher = input.required(); + cipher = input.required(); imageLoaded = signal(false); diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index cf017899774..75ca5608208 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -21,20 +21,23 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; @Directive() -export class VaultItemsComponent implements OnInit, OnDestroy { +export class VaultItemsComponent implements OnInit, OnDestroy { @Input() activeCipherId: string = null; - @Output() onCipherClicked = new EventEmitter(); - @Output() onCipherRightClicked = new EventEmitter(); + @Output() onCipherClicked = new EventEmitter(); + @Output() onCipherRightClicked = new EventEmitter(); @Output() onAddCipher = new EventEmitter(); @Output() onAddCipherOptions = new EventEmitter(); loaded = false; - ciphers: CipherView[] = []; + ciphers: C[] = []; deleted = false; organization: Organization; CipherType = CipherType; @@ -55,7 +58,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy { protected searchPending = false; /** Construct filters as an observable so it can be appended to the cipher stream. */ - private _filter$ = new BehaviorSubject<(cipher: CipherView) => boolean | null>(null); + private _filter$ = new BehaviorSubject<(cipher: C) => boolean | null>(null); private destroy$ = new Subject(); private isSearchable: boolean = false; private _searchText$ = new BehaviorSubject(""); @@ -71,7 +74,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy { return this._filter$.value; } - set filter(value: (cipher: CipherView) => boolean | null) { + set filter(value: (cipher: C) => boolean | null) { this._filter$.next(value); } @@ -102,13 +105,13 @@ export class VaultItemsComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - async load(filter: (cipher: CipherView) => boolean = null, deleted = false) { + async load(filter: (cipher: C) => boolean = null, deleted = false) { this.deleted = deleted ?? false; await this.applyFilter(filter); this.loaded = true; } - async reload(filter: (cipher: CipherView) => boolean = null, deleted = false) { + async reload(filter: (cipher: C) => boolean = null, deleted = false) { this.loaded = false; await this.load(filter, deleted); } @@ -117,15 +120,15 @@ export class VaultItemsComponent implements OnInit, OnDestroy { await this.reload(this.filter, this.deleted); } - async applyFilter(filter: (cipher: CipherView) => boolean = null) { + async applyFilter(filter: (cipher: C) => boolean = null) { this.filter = filter; } - selectCipher(cipher: CipherView) { + selectCipher(cipher: C) { this.onCipherClicked.emit(cipher); } - rightClickCipher(cipher: CipherView) { + rightClickCipher(cipher: C) { this.onCipherRightClicked.emit(cipher); } @@ -141,7 +144,8 @@ export class VaultItemsComponent implements OnInit, OnDestroy { return !this.searchPending && this.isSearchable; } - protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted; + protected deletedFilter: (cipher: C) => boolean = (c) => + CipherViewLikeUtils.isDeleted(c) === this.deleted; /** * Creates stream of dependencies that results in the list of ciphers to display @@ -156,7 +160,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy { .pipe( switchMap((userId) => combineLatest([ - this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)), + this.cipherService.cipherListViews$(userId).pipe(filter((ciphers) => ciphers != null)), this.cipherService.failedToDecryptCiphers$(userId), this._searchText$, this._filter$, @@ -165,12 +169,12 @@ export class VaultItemsComponent implements OnInit, OnDestroy { ]), ), switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId, restricted]) => { - let allCiphers = indexedCiphers ?? []; + let allCiphers = (indexedCiphers ?? []) as C[]; const _failedCiphers = failedCiphers ?? []; - allCiphers = [..._failedCiphers, ...allCiphers]; + allCiphers = [..._failedCiphers, ...allCiphers] as C[]; - const restrictedTypeFilter = (cipher: CipherView) => + const restrictedTypeFilter = (cipher: CipherViewLike) => !this.restrictedItemTypesService.isCipherRestricted(cipher, restricted); return this.searchService.searchCiphers( diff --git a/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts index 3122bdac2e0..8302ff541aa 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts @@ -25,7 +25,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService { nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { return combineLatest([ this.getNudgeStatus$(nudgeType, userId), - this.cipherService.cipherViews$(userId), + this.cipherService.cipherListViews$(userId), this.organizationService.organizations$(userId), this.collectionService.decryptedCollections$, ]).pipe( diff --git a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts index 8f63c31d87a..fa383dd28da 100644 --- a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts +++ b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts @@ -1,11 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { CipherStatus } from "./cipher-status.model"; -export type VaultFilterFunction = (cipher: CipherView) => boolean; +export type VaultFilterFunction = (cipher: CipherViewLike) => boolean; export class VaultFilter { cipherType?: CipherType; @@ -44,10 +47,10 @@ export class VaultFilter { cipherPassesFilter = cipher.favorite; } if (this.status === "trash" && cipherPassesFilter) { - cipherPassesFilter = cipher.isDeleted; + cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher); } if (this.cipherType != null && cipherPassesFilter) { - cipherPassesFilter = cipher.type === this.cipherType; + cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType; } if (this.selectedFolder && this.selectedFolderId == null && cipherPassesFilter) { cipherPassesFilter = cipher.folderId == null; @@ -68,7 +71,7 @@ export class VaultFilter { cipherPassesFilter = cipher.organizationId === this.selectedOrganizationId; } if (this.myVaultOnly && cipherPassesFilter) { - cipherPassesFilter = cipher.organizationId === null; + cipherPassesFilter = cipher.organizationId == null; } return cipherPassesFilter; }; diff --git a/libs/auth/src/angular/login-via-auth-request/auth_request_login_readme.md b/libs/auth/src/angular/login-via-auth-request/README.md similarity index 77% rename from libs/auth/src/angular/login-via-auth-request/auth_request_login_readme.md rename to libs/auth/src/angular/login-via-auth-request/README.md index 240316f788c..3396ba8698b 100644 --- a/libs/auth/src/angular/login-via-auth-request/auth_request_login_readme.md +++ b/libs/auth/src/angular/login-via-auth-request/README.md @@ -1,11 +1,22 @@ -# Authentication Flows Documentation +# Login via Auth Request Documentation + + + +**Table of Contents** + +> - [Standard Auth Request Flows](#standard-auth-request-flows) +> - [Admin Auth Request Flow](#admin-auth-request-flow) +> - [Summary Table](#summary-table) +> - [State Management](#state-management) + + ## Standard Auth Request Flows ### Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory 1. Unauthed user clicks "Login with device" -2. Navigates to /login-with-device which creates a StandardAuthRequest +2. Navigates to `/login-with-device` which creates a `StandardAuthRequest` 3. Receives approval from a device with authRequestPublicKey(masterKey) 4. Decrypts masterKey 5. Decrypts userKey @@ -14,7 +25,7 @@ ### Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory 1. Unauthed user clicks "Login with device" -2. Navigates to /login-with-device which creates a StandardAuthRequest +2. Navigates to `/login-with-device` which creates a `StandardAuthRequest` 3. Receives approval from a device with authRequestPublicKey(userKey) 4. Decrypts userKey 5. Proceeds to vault @@ -34,9 +45,9 @@ get into this flow: ### Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory 1. SSO TD user authenticates via SSO -2. Navigates to /login-initiated +2. Navigates to `/login-initiated` 3. Clicks "Approve from your other device" -4. Navigates to /login-with-device which creates a StandardAuthRequest +4. Navigates to `/login-with-device` which creates a `StandardAuthRequest` 5. Receives approval from device with authRequestPublicKey(masterKey) 6. Decrypts masterKey 7. Decrypts userKey @@ -46,22 +57,24 @@ get into this flow: ### Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory 1. SSO TD user authenticates via SSO -2. Navigates to /login-initiated +2. Navigates to `/login-initiated` 3. Clicks "Approve from your other device" -4. Navigates to /login-with-device which creates a StandardAuthRequest +4. Navigates to `/login-with-device` which creates a `StandardAuthRequest` 5. Receives approval from device with authRequestPublicKey(userKey) 6. Decrypts userKey 7. Establishes trust (if required) 8. Proceeds to vault + + ## Admin Auth Request Flow ### Flow: Authed SSO TD user requests admin approval 1. SSO TD user authenticates via SSO -2. Navigates to /login-initiated +2. Navigates to `/login-initiated` 3. Clicks "Request admin approval" -4. Navigates to /admin-approval-requested which creates an AdminAuthRequest +4. Navigates to `/admin-approval-requested` which creates an `AdminAuthRequest` 5. Receives approval from device with authRequestPublicKey(userKey) 6. Decrypts userKey 7. Establishes trust (if required) @@ -70,21 +83,25 @@ get into this flow: **Note:** TDE users are required to be enrolled in admin account recovery, which gives the admin access to the user's userKey. This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock. + + ## Summary Table -| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* | -| --------------- | ----------- | --------------------------------------------------- | ------------------------- | ------------------------------------------------- | -| Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes | -| Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no | -| Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes | -| Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no | -| Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey | +| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* | +| --------------- | ----------- | ----------------------------------------------------- | --------------------------- | ------------------------------------------------- | +| Standard Flow 1 | unauthed | "Login with device" [`/login`] | `/login-with-device` | yes | +| Standard Flow 2 | unauthed | "Login with device" [`/login`] | `/login-with-device` | no | +| Standard Flow 3 | authed | "Approve from your other device" [`/login-initiated`] | `/login-with-device` | yes | +| Standard Flow 4 | authed | "Approve from your other device" [`/login-initiated`] | `/login-with-device` | no | +| Admin Flow | authed | "Request admin approval"[`/login-initiated`] | `/admin-approval-requested` | NA - admin requests always send encrypted userKey | **Note:** The phrase "in memory" here is important. It is possible for a user to have a master password for their account, but not have a masterKey IN MEMORY for a specific device. For example, if a user registers an account with a master password, then joins an SSO TD org, then logs in to a device via SSO and admin auth request, they are now logged into that device but that device does not have masterKey IN MEMORY. + + ## State Management ### View Cache @@ -102,6 +119,8 @@ The cache is used to: 2. Allow resumption of pending auth requests 3. Enable processing of approved requests after extension close and reopen. + + ### Component State Variables Key state variables maintained during the authentication process: @@ -149,6 +168,8 @@ protected flow = Flow.StandardAuthRequest - Affects UI rendering and request handling - Set based on route and authentication state + + ### State Flow Examples #### Standard Auth Request Cache Flow @@ -186,6 +207,8 @@ protected flow = Flow.StandardAuthRequest - Either resumes monitoring or starts new request - Clears state after successful approval + + ### State Cleanup State cleanup occurs in several scenarios: diff --git a/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts b/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts index ba6890947c1..8c1fa61322b 100644 --- a/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts @@ -1,5 +1,7 @@ import { Observable } from "rxjs"; +import { DeviceType } from "@bitwarden/common/enums"; + import { DeviceResponse } from "./responses/device.response"; import { DeviceView } from "./views/device.view"; @@ -15,4 +17,5 @@ export abstract class DevicesServiceAbstraction { ): Observable; abstract deactivateDevice$(deviceId: string): Observable; abstract getCurrentDevice$(): Observable; + abstract getReadableDeviceTypeName(deviceType: DeviceType): string; } diff --git a/libs/common/src/auth/enums/authentication-status.ts b/libs/common/src/auth/enums/authentication-status.ts index 6a6f9467ae7..fe50bd5f39b 100644 --- a/libs/common/src/auth/enums/authentication-status.ts +++ b/libs/common/src/auth/enums/authentication-status.ts @@ -1,7 +1,28 @@ +/** + * The authentication status of the user + * + * See `AuthService.authStatusFor$` for details on how we determine the user's `AuthenticationStatus` + */ // FIXME: update to use a const object instead of a typescript enum // eslint-disable-next-line @bitwarden/platform/no-enums export enum AuthenticationStatus { + /** + * User is not authenticated + * - The user does not have an active account userId and/or an access token in state + */ LoggedOut = 0, + + /** + * User is authenticated but not decrypted + * - The user has an access token, but no user key in state + * - Vault data cannot be decrypted (because there is no user key) + */ Locked = 1, + + /** + * User is authenticated and decrypted + * - The user has an access token and a user key in state + * - Vault data can be decrypted (via user key) + */ Unlocked = 2, } diff --git a/libs/common/src/auth/services/devices/devices.service.implementation.ts b/libs/common/src/auth/services/devices/devices.service.implementation.ts index cdaa7a9fc4e..ba9b376576e 100644 --- a/libs/common/src/auth/services/devices/devices.service.implementation.ts +++ b/libs/common/src/auth/services/devices/devices.service.implementation.ts @@ -1,5 +1,8 @@ import { Observable, defer, map } from "rxjs"; +import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + import { ListResponse } from "../../../models/response/list.response"; import { AppIdService } from "../../../platform/abstractions/app-id.service"; import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction"; @@ -17,8 +20,9 @@ import { DevicesApiServiceAbstraction } from "../../abstractions/devices-api.ser */ export class DevicesServiceImplementation implements DevicesServiceAbstraction { constructor( - private devicesApiService: DevicesApiServiceAbstraction, private appIdService: AppIdService, + private devicesApiService: DevicesApiServiceAbstraction, + private i18nService: I18nService, ) {} /** @@ -86,4 +90,23 @@ export class DevicesServiceImplementation implements DevicesServiceAbstraction { return this.devicesApiService.getDeviceByIdentifier(deviceIdentifier); }); } + + /** + * @description Gets a human readable string of the device type name + */ + getReadableDeviceTypeName(type: DeviceType): string { + if (type === undefined) { + return this.i18nService.t("unknownDevice"); + } + + const metadata = DeviceTypeMetadata[type]; + if (!metadata) { + return this.i18nService.t("unknownDevice"); + } + + const platform = + metadata.platform === "Unknown" ? this.i18nService.t("unknown") : metadata.platform; + const category = this.i18nService.t(metadata.category); + return platform ? `${category} - ${platform}` : category; + } } diff --git a/libs/common/src/enums/device-type.enum.ts b/libs/common/src/enums/device-type.enum.ts index c462081140e..f7215ac7446 100644 --- a/libs/common/src/enums/device-type.enum.ts +++ b/libs/common/src/enums/device-type.enum.ts @@ -35,7 +35,7 @@ export enum DeviceType { * Each device type has a category corresponding to the client type and platform (Android, iOS, Chrome, Firefox, etc.) */ interface DeviceTypeMetadata { - category: "mobile" | "extension" | "webVault" | "desktop" | "cli" | "sdk" | "server"; + category: "mobile" | "extension" | "webApp" | "desktop" | "cli" | "sdk" | "server"; platform: string; } @@ -49,15 +49,15 @@ export const DeviceTypeMetadata: Record = { [DeviceType.EdgeExtension]: { category: "extension", platform: "Edge" }, [DeviceType.VivaldiExtension]: { category: "extension", platform: "Vivaldi" }, [DeviceType.SafariExtension]: { category: "extension", platform: "Safari" }, - [DeviceType.ChromeBrowser]: { category: "webVault", platform: "Chrome" }, - [DeviceType.FirefoxBrowser]: { category: "webVault", platform: "Firefox" }, - [DeviceType.OperaBrowser]: { category: "webVault", platform: "Opera" }, - [DeviceType.EdgeBrowser]: { category: "webVault", platform: "Edge" }, - [DeviceType.IEBrowser]: { category: "webVault", platform: "IE" }, - [DeviceType.SafariBrowser]: { category: "webVault", platform: "Safari" }, - [DeviceType.VivaldiBrowser]: { category: "webVault", platform: "Vivaldi" }, - [DeviceType.DuckDuckGoBrowser]: { category: "webVault", platform: "DuckDuckGo" }, - [DeviceType.UnknownBrowser]: { category: "webVault", platform: "Unknown" }, + [DeviceType.ChromeBrowser]: { category: "webApp", platform: "Chrome" }, + [DeviceType.FirefoxBrowser]: { category: "webApp", platform: "Firefox" }, + [DeviceType.OperaBrowser]: { category: "webApp", platform: "Opera" }, + [DeviceType.EdgeBrowser]: { category: "webApp", platform: "Edge" }, + [DeviceType.IEBrowser]: { category: "webApp", platform: "IE" }, + [DeviceType.SafariBrowser]: { category: "webApp", platform: "Safari" }, + [DeviceType.VivaldiBrowser]: { category: "webApp", platform: "Vivaldi" }, + [DeviceType.DuckDuckGoBrowser]: { category: "webApp", platform: "DuckDuckGo" }, + [DeviceType.UnknownBrowser]: { category: "webApp", platform: "Unknown" }, [DeviceType.WindowsDesktop]: { category: "desktop", platform: "Windows" }, [DeviceType.MacOsDesktop]: { category: "desktop", platform: "macOS" }, [DeviceType.LinuxDesktop]: { category: "desktop", platform: "Linux" }, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index da14f7dada3..1af2ab1f0a9 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -53,6 +53,7 @@ export enum FeatureFlag { PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge", PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form", PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", + PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", CipherKeyEncryption = "cipher-key-encryption", EndUserNotifications = "pm-10609-end-user-notifications", RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy", @@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EndUserNotifications]: FALSE, [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.RemoveCardItemTypePolicy]: FALSE, + [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM19315EndUserActivationMvp]: FALSE, /* Auth */ diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 9f5c173826e..d1d686a66af 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -5,6 +5,7 @@ import { Observable } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { UserKeyRotationDataProvider } from "@bitwarden/key-management"; +import { CipherListView } from "@bitwarden/sdk-internal"; import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; @@ -20,6 +21,7 @@ import { AttachmentView } from "../models/view/attachment.view"; import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; +import { CipherViewLike } from "../utils/cipher-view-like-utils"; export type EncryptionContext = { cipher: Cipher; @@ -29,6 +31,7 @@ export type EncryptionContext = { export abstract class CipherService implements UserKeyRotationDataProvider { abstract cipherViews$(userId: UserId): Observable; + abstract cipherListViews$(userId: UserId): Observable; abstract ciphers$(userId: UserId): Observable>; abstract localData$(userId: UserId): Observable>; /** @@ -65,12 +68,12 @@ export abstract class CipherService implements UserKeyRotationDataProvider; - abstract filterCiphersForUrl( - ciphers: CipherView[], + abstract filterCiphersForUrl( + ciphers: C[], url: string, includeOtherTypes?: CipherType[], defaultMatch?: UriMatchStrategySetting, - ): Promise; + ): Promise; abstract getAllFromApiForOrganization(organizationId: string): Promise; /** * Gets ciphers belonging to the specified organization that the user has explicit collection level access to. @@ -198,9 +201,9 @@ export abstract class CipherService implements UserKeyRotationDataProvider; - abstract sortCiphersByLastUsed(a: CipherView, b: CipherView): number; - abstract sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number; - abstract getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number; + abstract sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number; + abstract sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number; + abstract getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number; abstract softDelete(id: string | string[], userId: UserId): Promise; abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise; @@ -251,4 +254,10 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + + /** + * Decrypts the full `CipherView` for a given `CipherViewLike`. + * When a `CipherView` instance is passed, it returns it as is. + */ + abstract getFullCipherView(c: CipherViewLike): Promise; } diff --git a/libs/common/src/vault/abstractions/search.service.ts b/libs/common/src/vault/abstractions/search.service.ts index c981aa748a4..ed8bb2c3baf 100644 --- a/libs/common/src/vault/abstractions/search.service.ts +++ b/libs/common/src/vault/abstractions/search.service.ts @@ -5,6 +5,7 @@ import { Observable } from "rxjs"; import { SendView } from "../../tools/send/models/view/send.view"; import { IndexedEntityId, UserId } from "../../types/guid"; import { CipherView } from "../models/view/cipher.view"; +import { CipherViewLike } from "../utils/cipher-view-like-utils"; export abstract class SearchService { indexedEntityId$: (userId: UserId) => Observable; @@ -16,12 +17,16 @@ export abstract class SearchService { ciphersToIndex: CipherView[], indexedEntityGuid?: string, ) => Promise; - searchCiphers: ( + searchCiphers: ( userId: UserId, query: string, - filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[], - ciphers?: CipherView[], - ) => Promise; - searchCiphersBasic: (ciphers: CipherView[], query: string, deleted?: boolean) => CipherView[]; + filter?: ((cipher: C) => boolean) | ((cipher: C) => boolean)[], + ciphers?: C[], + ) => Promise; + searchCiphersBasic: ( + ciphers: C[], + query: string, + deleted?: boolean, + ) => C[]; searchSends: (sends: SendView[], query: string) => SendView[]; } diff --git a/libs/common/src/vault/icon/build-cipher-icon.ts b/libs/common/src/vault/icon/build-cipher-icon.ts index b7456e1ae96..a081511d792 100644 --- a/libs/common/src/vault/icon/build-cipher-icon.ts +++ b/libs/common/src/vault/icon/build-cipher-icon.ts @@ -1,6 +1,6 @@ import { Utils } from "../../platform/misc/utils"; import { CipherType } from "../enums/cipher-type"; -import { CipherView } from "../models/view/cipher.view"; +import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; export interface CipherIconDetails { imageEnabled: boolean; @@ -14,7 +14,7 @@ export interface CipherIconDetails { export function buildCipherIcon( iconsServerUrl: string | null, - cipher: CipherView, + cipher: CipherViewLike, showFavicon: boolean, ): CipherIconDetails { let icon: string = "bwi-globe"; @@ -36,12 +36,16 @@ export function buildCipherIcon( showFavicon = false; } - switch (cipher.type) { + const cipherType = CipherViewLikeUtils.getType(cipher); + const uri = CipherViewLikeUtils.uri(cipher); + const card = CipherViewLikeUtils.getCard(cipher); + + switch (cipherType) { case CipherType.Login: icon = "bwi-globe"; - if (cipher.login.uri) { - let hostnameUri = cipher.login.uri; + if (uri) { + let hostnameUri = uri; let isWebsite = false; if (hostnameUri.indexOf("androidapp://") === 0) { @@ -84,8 +88,8 @@ export function buildCipherIcon( break; case CipherType.Card: icon = "bwi-credit-card"; - if (showFavicon && cipher.card.brand in cardIcons) { - icon = `credit-card-icon ${cardIcons[cipher.card.brand]}`; + if (showFavicon && card?.brand && card.brand in cardIcons) { + icon = `credit-card-icon ${cardIcons[card.brand]}`; } break; case CipherType.Identity: diff --git a/libs/common/src/vault/services/cipher-authorization.service.ts b/libs/common/src/vault/services/cipher-authorization.service.ts index ab3676930b5..2933e94c302 100644 --- a/libs/common/src/vault/services/cipher-authorization.service.ts +++ b/libs/common/src/vault/services/cipher-authorization.service.ts @@ -8,13 +8,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { CollectionId } from "@bitwarden/common/types/guid"; import { getUserId } from "../../auth/services/account.service"; -import { Cipher } from "../models/domain/cipher"; -import { CipherView } from "../models/view/cipher.view"; - -/** - * Represents either a cipher or a cipher view. - */ -type CipherLike = Cipher | CipherView; +import { CipherLike } from "../types/cipher-like"; /** * Service for managing user cipher authorization. @@ -95,7 +89,7 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer } } - return cipher.permissions.delete; + return !!cipher.permissions?.delete; }), ); } @@ -118,7 +112,7 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer } } - return cipher.permissions.restore; + return !!cipher.permissions?.restore; }), ); } diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index cf9dfcaf1cd..8bef5289a95 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -71,6 +71,7 @@ import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; import { PasswordHistoryView } from "../models/view/password-history.view"; import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; +import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; import { ADD_EDIT_CIPHER_INFO_KEY, @@ -123,6 +124,43 @@ export class CipherService implements CipherServiceAbstraction { return this.encryptedCiphersState(userId).state$.pipe(map((ciphers) => ciphers ?? {})); } + /** + * Observable that emits an array of decrypted ciphers for given userId. + * This observable will not emit until the encrypted ciphers have either been loaded from state or after sync. + * + * This uses the SDK for decryption, when the `PM22134SdkCipherListView` feature flag is disabled the full `cipherViews$` observable will be emitted. + * Usage of the {@link CipherViewLike} type is recommended to ensure both `CipherView` and `CipherListView` are supported. + */ + cipherListViews$ = perUserCache$((userId: UserId) => { + return this.configService.getFeatureFlag$(FeatureFlag.PM22134SdkCipherListView).pipe( + switchMap((useSdk) => { + if (!useSdk) { + return this.cipherViews$(userId); + } + + return combineLatest([ + this.encryptedCiphersState(userId).state$, + this.localData$(userId), + this.keyService.cipherDecryptionKeys$(userId, true), + ]).pipe( + filter(([cipherDataState, _, keys]) => cipherDataState != null && keys != null), + map(([cipherDataState, localData]) => + Object.values(cipherDataState).map( + (cipherData) => new Cipher(cipherData, localData?.[cipherData.id as CipherId]), + ), + ), + switchMap(async (ciphers) => { + // TODO: remove this once failed decrypted ciphers are handled in the SDK + await this.setFailedDecryptedCiphers([], userId); + return this.cipherEncryptionService + .decryptMany(ciphers, userId) + .then((ciphers) => ciphers.sort(this.getLocaleSortingFunction())); + }), + ); + }), + ); + }); + /** * Observable that emits an array of decrypted ciphers for the active user. * This observable will not emit until the encrypted ciphers have either been loaded from state or after sync. @@ -419,11 +457,13 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, ): Promise<[CipherView[], CipherView[]] | null> { if (await this.configService.getFeatureFlag(FeatureFlag.PM19941MigrateCipherDomainToSdk)) { - const decryptStartTime = new Date().getTime(); + const decryptStartTime = performance.now(); const decrypted = await this.decryptCiphersWithSdk(ciphers, userId); - this.logService.info( - `[CipherService] Decrypting ${decrypted.length} ciphers took ${new Date().getTime() - decryptStartTime}ms`, - ); + + this.logService.measure(decryptStartTime, "Vault", "CipherService", "decrypt complete", [ + ["Items", ciphers.length], + ]); + // With SDK, failed ciphers are not returned return [decrypted, []]; } @@ -442,7 +482,7 @@ export class CipherService implements CipherServiceAbstraction { }, {} as Record, ); - const decryptStartTime = new Date().getTime(); + const decryptStartTime = performance.now(); const allCipherViews = ( await Promise.all( Object.entries(grouped).map(async ([orgId, groupedCiphers]) => { @@ -462,9 +502,11 @@ export class CipherService implements CipherServiceAbstraction { ) .flat() .sort(this.getLocaleSortingFunction()); - this.logService.info( - `[CipherService] Decrypting ${allCipherViews.length} ciphers took ${new Date().getTime() - decryptStartTime}ms`, - ); + + this.logService.measure(decryptStartTime, "Vault", "CipherService", "decrypt complete", [ + ["Items", ciphers.length], + ]); + // Split ciphers into two arrays, one for successfully decrypted ciphers and one for ciphers that failed to decrypt return allCipherViews.reduce( (acc, c) => { @@ -539,18 +581,23 @@ export class CipherService implements CipherServiceAbstraction { filter((c) => c != null), switchMap( async (ciphers) => - await this.filterCiphersForUrl(ciphers, url, includeOtherTypes, defaultMatch), + await this.filterCiphersForUrl( + ciphers, + url, + includeOtherTypes, + defaultMatch, + ), ), ), ); } - async filterCiphersForUrl( - ciphers: CipherView[], + async filterCiphersForUrl( + ciphers: C[], url: string, includeOtherTypes?: CipherType[], defaultMatch: UriMatchStrategySetting = null, - ): Promise { + ): Promise { if (url == null && includeOtherTypes == null) { return []; } @@ -561,22 +608,20 @@ export class CipherService implements CipherServiceAbstraction { defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$); return ciphers.filter((cipher) => { - const cipherIsLogin = cipher.type === CipherType.Login && cipher.login !== null; + const type = CipherViewLikeUtils.getType(cipher); + const login = CipherViewLikeUtils.getLogin(cipher); + const cipherIsLogin = login !== null; - if (cipher.deletedDate !== null) { + if (CipherViewLikeUtils.isDeleted(cipher)) { return false; } - if ( - Array.isArray(includeOtherTypes) && - includeOtherTypes.includes(cipher.type) && - !cipherIsLogin - ) { + if (Array.isArray(includeOtherTypes) && includeOtherTypes.includes(type) && !cipherIsLogin) { return true; } if (cipherIsLogin) { - return cipher.login.matchesUri(url, equivalentDomains, defaultMatch); + return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch); } return false; @@ -1169,7 +1214,7 @@ export class CipherService implements CipherServiceAbstraction { return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId, userId); } - sortCiphersByLastUsed(a: CipherView, b: CipherView): number { + sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number { const aLastUsed = a.localData && a.localData.lastUsedDate ? (a.localData.lastUsedDate as number) : null; const bLastUsed = @@ -1193,7 +1238,7 @@ export class CipherService implements CipherServiceAbstraction { return 0; } - sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number { + sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number { const result = this.sortCiphersByLastUsed(a, b); if (result !== 0) { return result; @@ -1202,7 +1247,7 @@ export class CipherService implements CipherServiceAbstraction { return this.getLocaleSortingFunction()(a, b); } - getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number { + getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number { return (a, b) => { let aName = a.name; let bName = b.name; @@ -1221,16 +1266,22 @@ export class CipherService implements CipherServiceAbstraction { ? this.i18nService.collator.compare(aName, bName) : aName.localeCompare(bName); - if (result !== 0 || a.type !== CipherType.Login || b.type !== CipherType.Login) { + const aType = CipherViewLikeUtils.getType(a); + const bType = CipherViewLikeUtils.getType(b); + + if (result !== 0 || aType !== CipherType.Login || bType !== CipherType.Login) { return result; } - if (a.login.username != null) { - aName += a.login.username; + const aLogin = CipherViewLikeUtils.getLogin(a); + const bLogin = CipherViewLikeUtils.getLogin(b); + + if (aLogin.username != null) { + aName += aLogin.username; } - if (b.login.username != null) { - bName += b.login.username; + if (bLogin.username != null) { + bName += bLogin.username; } return this.i18nService.collator @@ -1898,4 +1949,17 @@ export class CipherService implements CipherServiceAbstraction { return decryptedViews.sort(this.getLocaleSortingFunction()); } + + /** Fetches the full `CipherView` when a `CipherListView` is passed. */ + async getFullCipherView(c: CipherViewLike): Promise { + if (CipherViewLikeUtils.isCipherListView(c)) { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + const cipher = await this.get(c.id!, activeUserId); + return this.decrypt(cipher, activeUserId); + } + + return Promise.resolve(c); + } } diff --git a/libs/common/src/vault/services/folder/folder.service.ts b/libs/common/src/vault/services/folder/folder.service.ts index 12d02958049..2d440adeb29 100644 --- a/libs/common/src/vault/services/folder/folder.service.ts +++ b/libs/common/src/vault/services/folder/folder.service.ts @@ -1,6 +1,16 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Observable, Subject, firstValueFrom, map, shareReplay, switchMap, merge } from "rxjs"; +import { + Observable, + Subject, + firstValueFrom, + map, + shareReplay, + switchMap, + merge, + filter, + combineLatest, +} from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -69,8 +79,12 @@ export class FolderService implements InternalFolderServiceAbstraction { const observable = merge( this.forceFolderViews[userId], - this.encryptedFoldersState(userId).state$.pipe( - switchMap((folderData) => { + combineLatest([ + this.encryptedFoldersState(userId).state$, + this.keyService.userKey$(userId), + ]).pipe( + filter(([folderData, userKey]) => folderData != null && userKey != null), + switchMap(([folderData, _]) => { return this.decryptFolders(userId, folderData); }), ), diff --git a/libs/common/src/vault/services/restricted-item-types.service.ts b/libs/common/src/vault/services/restricted-item-types.service.ts index 6b848e6626b..8ccc94d365c 100644 --- a/libs/common/src/vault/services/restricted-item-types.service.ts +++ b/libs/common/src/vault/services/restricted-item-types.service.ts @@ -9,17 +9,15 @@ 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"; -import { Cipher } from "../models/domain/cipher"; +import { CipherLike } from "../types/cipher-like"; +import { CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; export type RestrictedCipherType = { cipherType: CipherType; allowViewOrgIds: string[]; }; -type CipherLike = Cipher | CipherView; - export class RestrictedItemTypesService { /** * Emits an array of RestrictedCipherType objects: @@ -94,7 +92,9 @@ export class RestrictedItemTypesService { * - Otherwise → restricted */ isCipherRestricted(cipher: CipherLike, restrictedTypes: RestrictedCipherType[]): boolean { - const restriction = restrictedTypes.find((r) => r.cipherType === cipher.type); + const restriction = restrictedTypes.find( + (r) => r.cipherType === CipherViewLikeUtils.getType(cipher), + ); // If cipher type is not restricted by any organization, allow it if (!restriction) { diff --git a/libs/common/src/vault/services/search.service.ts b/libs/common/src/vault/services/search.service.ts index 4b7a26b6a31..614fba4a7ca 100644 --- a/libs/common/src/vault/services/search.service.ts +++ b/libs/common/src/vault/services/search.service.ts @@ -19,6 +19,7 @@ import { SearchService as SearchServiceAbstraction } from "../abstractions/searc import { FieldType } from "../enums"; import { CipherType } from "../enums/cipher-type"; import { CipherView } from "../models/view/cipher.view"; +import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; export type SerializedLunrIndex = { version: string; @@ -129,12 +130,15 @@ export class SearchService implements SearchServiceAbstraction { } async isSearchable(userId: UserId, query: string): Promise { + const time = performance.now(); query = SearchService.normalizeSearchQuery(query); const index = await this.getIndexForSearch(userId); const notSearchable = query == null || (index == null && query.length < this.searchableMinLength) || (index != null && query.length < this.searchableMinLength && query.indexOf(">") !== 0); + + this.logService.measure(time, "Vault", "SearchService", "isSearchable"); return !notSearchable; } @@ -147,7 +151,7 @@ export class SearchService implements SearchServiceAbstraction { return; } - const indexingStartTime = new Date().getTime(); + const indexingStartTime = performance.now(); await this.setIsIndexing(userId, true); await this.setIndexedEntityIdForSearch(userId, indexedEntityId as IndexedEntityId); const builder = new lunr.Builder(); @@ -188,20 +192,19 @@ export class SearchService implements SearchServiceAbstraction { await this.setIndexForSearch(userId, index.toJSON() as SerializedLunrIndex); await this.setIsIndexing(userId, false); - this.logService.info( - `[SearchService] Building search index of ${ciphers.length} ciphers took ${ - new Date().getTime() - indexingStartTime - }ms`, - ); + + this.logService.measure(indexingStartTime, "Vault", "SearchService", "index complete", [ + ["Items", ciphers.length], + ]); } - async searchCiphers( + async searchCiphers( userId: UserId, query: string, - filter: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[] = null, - ciphers: CipherView[], - ): Promise { - const results: CipherView[] = []; + filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null, + ciphers: C[], + ): Promise { + const results: C[] = []; if (query != null) { query = SearchService.normalizeSearchQuery(query.trim().toLowerCase()); } @@ -216,7 +219,7 @@ export class SearchService implements SearchServiceAbstraction { if (filter != null && Array.isArray(filter) && filter.length > 0) { ciphers = ciphers.filter((c) => filter.every((f) => f == null || f(c))); } else if (filter != null) { - ciphers = ciphers.filter(filter as (cipher: CipherView) => boolean); + ciphers = ciphers.filter(filter as (cipher: C) => boolean); } if (!(await this.isSearchable(userId, query))) { @@ -236,7 +239,7 @@ export class SearchService implements SearchServiceAbstraction { return this.searchCiphersBasic(ciphers, query); } - const ciphersMap = new Map(); + const ciphersMap = new Map(); ciphers.forEach((c) => ciphersMap.set(c.id, c)); let searchResults: lunr.Index.Result[] = null; @@ -270,10 +273,10 @@ export class SearchService implements SearchServiceAbstraction { return results; } - searchCiphersBasic(ciphers: CipherView[], query: string, deleted = false) { + searchCiphersBasic(ciphers: C[], query: string, deleted = false) { query = SearchService.normalizeSearchQuery(query.trim().toLowerCase()); return ciphers.filter((c) => { - if (deleted !== c.isDeleted) { + if (deleted !== CipherViewLikeUtils.isDeleted(c)) { return false; } if (c.name != null && c.name.toLowerCase().indexOf(query) > -1) { @@ -282,13 +285,17 @@ export class SearchService implements SearchServiceAbstraction { if (query.length >= 8 && c.id.startsWith(query)) { return true; } - if (c.subTitle != null && c.subTitle.toLowerCase().indexOf(query) > -1) { + const subtitle = CipherViewLikeUtils.subtitle(c); + if (subtitle != null && subtitle.toLowerCase().indexOf(query) > -1) { return true; } + + const login = CipherViewLikeUtils.getLogin(c); + if ( - c.login && - c.login.hasUris && - c.login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1) + login && + login.uris.length && + login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1) ) { return true; } diff --git a/libs/common/src/vault/types/cipher-like.ts b/libs/common/src/vault/types/cipher-like.ts new file mode 100644 index 00000000000..61fb4ef86a5 --- /dev/null +++ b/libs/common/src/vault/types/cipher-like.ts @@ -0,0 +1,9 @@ +import { Cipher } from "../models/domain/cipher"; +import { CipherViewLike } from "../utils/cipher-view-like-utils"; + +/** + * Represents either a Cipher, CipherView or CipherListView. + * + * {@link CipherViewLikeUtils} provides logic to perform operations on each type. + */ +export type CipherLike = Cipher | CipherViewLike; diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts new file mode 100644 index 00000000000..f302340ef9e --- /dev/null +++ b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts @@ -0,0 +1,624 @@ +import { CipherListView } from "@bitwarden/sdk-internal"; + +import { CipherType } from "../enums"; +import { Attachment } from "../models/domain/attachment"; +import { AttachmentView } from "../models/view/attachment.view"; +import { CipherView } from "../models/view/cipher.view"; +import { Fido2CredentialView } from "../models/view/fido2-credential.view"; +import { IdentityView } from "../models/view/identity.view"; +import { LoginUriView } from "../models/view/login-uri.view"; +import { LoginView } from "../models/view/login.view"; + +import { CipherViewLikeUtils } from "./cipher-view-like-utils"; + +describe("CipherViewLikeUtils", () => { + const createCipherView = (type: CipherType = CipherType.Login): CipherView => { + const cipherView = new CipherView(); + // Always set a type to avoid issues within `CipherViewLikeUtils` + cipherView.type = type; + + return cipherView; + }; + + describe("isCipherListView", () => { + it("returns true when the cipher is a CipherListView", () => { + const cipherListViewLogin = { + type: { + login: {}, + }, + } as CipherListView; + const cipherListViewSshKey = { + type: "sshKey", + } as CipherListView; + + expect(CipherViewLikeUtils.isCipherListView(cipherListViewLogin)).toBe(true); + expect(CipherViewLikeUtils.isCipherListView(cipherListViewSshKey)).toBe(true); + }); + + it("returns false when the cipher is not a CipherListView", () => { + const cipherView = createCipherView(); + cipherView.type = CipherType.SecureNote; + + expect(CipherViewLikeUtils.isCipherListView(cipherView)).toBe(false); + }); + }); + + describe("getLogin", () => { + it("returns null when the cipher is not a login", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.getLogin(cipherView)).toBeNull(); + expect(CipherViewLikeUtils.getLogin({ type: "identity" } as CipherListView)).toBeNull(); + }); + + describe("CipherView", () => { + it("returns the login object", () => { + const cipherView = createCipherView(CipherType.Login); + + expect(CipherViewLikeUtils.getLogin(cipherView)).toEqual(cipherView.login); + }); + }); + + describe("CipherListView", () => { + it("returns the login object", () => { + const cipherListView = { + type: { + login: { + username: "testuser", + hasFido2: false, + }, + }, + } as CipherListView; + + expect(CipherViewLikeUtils.getLogin(cipherListView)).toEqual( + (cipherListView.type as any).login, + ); + }); + }); + }); + + describe("getCard", () => { + it("returns null when the cipher is not a card", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.getCard(cipherView)).toBeNull(); + expect(CipherViewLikeUtils.getCard({ type: "identity" } as CipherListView)).toBeNull(); + }); + + describe("CipherView", () => { + it("returns the card object", () => { + const cipherView = createCipherView(CipherType.Card); + + expect(CipherViewLikeUtils.getCard(cipherView)).toEqual(cipherView.card); + }); + }); + + describe("CipherListView", () => { + it("returns the card object", () => { + const cipherListView = { + type: { + card: { + brand: "Visa", + }, + }, + } as CipherListView; + + expect(CipherViewLikeUtils.getCard(cipherListView)).toEqual( + (cipherListView.type as any).card, + ); + }); + }); + }); + + describe("isDeleted", () => { + it("returns true when the cipher is deleted", () => { + const cipherListView = { deletedDate: "2024-02-02", type: "identity" } as CipherListView; + const cipherView = createCipherView(); + cipherView.deletedDate = new Date(); + + expect(CipherViewLikeUtils.isDeleted(cipherListView)).toBe(true); + expect(CipherViewLikeUtils.isDeleted(cipherView)).toBe(true); + }); + + it("returns false when the cipher is not deleted", () => { + const cipherListView = { deletedDate: undefined, type: "identity" } as CipherListView; + const cipherView = createCipherView(); + + expect(CipherViewLikeUtils.isDeleted(cipherListView)).toBe(false); + expect(CipherViewLikeUtils.isDeleted(cipherView)).toBe(false); + }); + }); + + describe("canAssignToCollections", () => { + describe("CipherView", () => { + let cipherView: CipherView; + + beforeEach(() => { + cipherView = createCipherView(); + }); + + it("returns true when the cipher is not assigned to an organization", () => { + expect(CipherViewLikeUtils.canAssignToCollections(cipherView)).toBe(true); + }); + + it("returns false when the cipher is assigned to an organization and cannot be edited", () => { + cipherView.organizationId = "org-id"; + cipherView.edit = false; + cipherView.viewPassword = false; + + expect(CipherViewLikeUtils.canAssignToCollections(cipherView)).toBe(false); + }); + + it("returns true when the cipher is assigned to an organization and can be edited", () => { + cipherView.organizationId = "org-id"; + cipherView.edit = true; + cipherView.viewPassword = true; + + expect(CipherViewLikeUtils.canAssignToCollections(cipherView)).toBe(true); + }); + }); + + describe("CipherListView", () => { + let cipherListView: CipherListView; + + beforeEach(() => { + cipherListView = { + organizationId: undefined, + edit: false, + viewPassword: false, + type: { login: {} }, + } as CipherListView; + }); + + it("returns true when the cipher is not assigned to an organization", () => { + expect(CipherViewLikeUtils.canAssignToCollections(cipherListView)).toBe(true); + }); + + it("returns false when the cipher is assigned to an organization and cannot be edited", () => { + cipherListView.organizationId = "org-id"; + + expect(CipherViewLikeUtils.canAssignToCollections(cipherListView)).toBe(false); + }); + + it("returns true when the cipher is assigned to an organization and can be edited", () => { + cipherListView.organizationId = "org-id"; + cipherListView.edit = true; + cipherListView.viewPassword = true; + + expect(CipherViewLikeUtils.canAssignToCollections(cipherListView)).toBe(true); + }); + }); + }); + + describe("getType", () => { + describe("CipherView", () => { + it("returns the type of the cipher", () => { + const cipherView = createCipherView(); + cipherView.type = CipherType.Login; + + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.Login); + + cipherView.type = CipherType.SecureNote; + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.SecureNote); + + cipherView.type = CipherType.SshKey; + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.SshKey); + + cipherView.type = CipherType.Identity; + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.Identity); + + cipherView.type = CipherType.Card; + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.Card); + }); + }); + + describe("CipherListView", () => { + it("converts the `CipherViewListType` to `CipherType`", () => { + const cipherListView = { + type: { login: {} }, + } as CipherListView; + + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.Login); + + cipherListView.type = { card: { brand: "Visa" } }; + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.Card); + + cipherListView.type = "sshKey"; + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.SshKey); + + cipherListView.type = "identity"; + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.Identity); + + cipherListView.type = "secureNote"; + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.SecureNote); + }); + }); + }); + + describe("subtitle", () => { + describe("CipherView", () => { + it("returns the subtitle of the cipher", () => { + const cipherView = createCipherView(); + cipherView.login = new LoginView(); + cipherView.login.username = "Test Username"; + + expect(CipherViewLikeUtils.subtitle(cipherView)).toBe("Test Username"); + }); + }); + + describe("CipherListView", () => { + it("returns the subtitle of the cipher", () => { + const cipherListView = { + subtitle: "Test Subtitle", + type: "identity", + } as CipherListView; + + expect(CipherViewLikeUtils.subtitle(cipherListView)).toBe("Test Subtitle"); + }); + }); + }); + + describe("hasAttachments", () => { + describe("CipherView", () => { + it("returns true when the cipher has attachments", () => { + const cipherView = createCipherView(); + cipherView.attachments = [new AttachmentView({ id: "1" } as Attachment)]; + + expect(CipherViewLikeUtils.hasAttachments(cipherView)).toBe(true); + }); + + it("returns false when the cipher has no attachments", () => { + const cipherView = new CipherView(); + (cipherView.attachments as any) = null; + + expect(CipherViewLikeUtils.hasAttachments(cipherView)).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true when there are attachments", () => { + const cipherListView = { attachments: 1, type: "secureNote" } as CipherListView; + + expect(CipherViewLikeUtils.hasAttachments(cipherListView)).toBe(true); + }); + + it("returns false when there are no attachments", () => { + const cipherListView = { attachments: 0, type: "secureNote" } as CipherListView; + + expect(CipherViewLikeUtils.hasAttachments(cipherListView)).toBe(false); + }); + }); + }); + + describe("canLaunch", () => { + it("returns false when the cipher is not a login", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(false); + expect(CipherViewLikeUtils.canLaunch({ type: "identity" } as CipherListView)).toBe(false); + }); + + describe("CipherView", () => { + it("returns true when the login has URIs that can be launched", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + cipherView.login.uris = [{ uri: "https://example.com" } as LoginUriView]; + + expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(true); + }); + + it("returns true when the uri does not have a protocol", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + const uriView = new LoginUriView(); + uriView.uri = "bitwarden.com"; + cipherView.login.uris = [uriView]; + + expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(true); + }); + + it("returns false when the login has no URIs", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + + expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true when the login has URIs that can be launched", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "https://example.com" }] } }, + } as CipherListView; + + expect(CipherViewLikeUtils.canLaunch(cipherListView)).toBe(true); + }); + + it("returns true when the uri does not have a protocol", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "bitwarden.com" }] } }, + } as CipherListView; + + expect(CipherViewLikeUtils.canLaunch(cipherListView)).toBe(true); + }); + + it("returns false when the login has no URIs", () => { + const cipherListView = { type: { login: {} } } as CipherListView; + + expect(CipherViewLikeUtils.canLaunch(cipherListView)).toBe(false); + }); + }); + }); + + describe("getLaunchUri", () => { + it("returns undefined when the cipher is not a login", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBeUndefined(); + expect( + CipherViewLikeUtils.getLaunchUri({ type: "identity" } as CipherListView), + ).toBeUndefined(); + }); + + describe("CipherView", () => { + it("returns the first launch-able URI", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + cipherView.login.uris = [ + { uri: "" } as LoginUriView, + { uri: "https://example.com" } as LoginUriView, + { uri: "https://another.com" } as LoginUriView, + ]; + + expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBe("https://example.com"); + }); + + it("returns undefined when there are no URIs", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + + expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBeUndefined(); + }); + + it("appends protocol when there are none", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + const uriView = new LoginUriView(); + uriView.uri = "bitwarden.com"; + cipherView.login.uris = [uriView]; + + expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBe("http://bitwarden.com"); + }); + }); + + describe("CipherListView", () => { + it("returns the first launch-able URI", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "" }, { uri: "https://example.com" }] } }, + } as CipherListView; + + expect(CipherViewLikeUtils.getLaunchUri(cipherListView)).toBe("https://example.com"); + }); + + it("returns undefined when there are no URIs", () => { + const cipherListView = { type: { login: {} } } as CipherListView; + + expect(CipherViewLikeUtils.getLaunchUri(cipherListView)).toBeUndefined(); + }); + }); + }); + + describe("matchesUri", () => { + const emptySet = new Set(); + + it("returns false when the cipher is not a login", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.matchesUri(cipherView, "https://example.com", emptySet)).toBe( + false, + ); + }); + + describe("CipherView", () => { + it("returns true when the URI matches", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + const uri = new LoginUriView(); + uri.uri = "https://example.com"; + cipherView.login.uris = [uri]; + + expect(CipherViewLikeUtils.matchesUri(cipherView, "https://example.com", emptySet)).toBe( + true, + ); + }); + + it("returns false when the URI does not match", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + const uri = new LoginUriView(); + uri.uri = "https://www.bitwarden.com"; + cipherView.login.uris = [uri]; + + expect( + CipherViewLikeUtils.matchesUri(cipherView, "https://www.another.com", emptySet), + ).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true when the URI matches", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "https://example.com" }] } }, + } as CipherListView; + + expect( + CipherViewLikeUtils.matchesUri(cipherListView, "https://example.com", emptySet), + ).toBe(true); + }); + + it("returns false when the URI does not match", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "https://bitwarden.com" }] } }, + } as CipherListView; + + expect( + CipherViewLikeUtils.matchesUri(cipherListView, "https://another.com", emptySet), + ).toBe(false); + }); + }); + }); + + describe("hasCopyableValue", () => { + describe("CipherView", () => { + it("returns true for login fields", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + cipherView.login.username = "testuser"; + cipherView.login.password = "testpass"; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "username")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "password")).toBe(true); + }); + + it("returns true for card fields", () => { + const cipherView = createCipherView(CipherType.Card); + cipherView.card = { number: "1234-5678-9012-3456", code: "123" } as any; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "cardNumber")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "securityCode")).toBe(true); + }); + + it("returns true for identity fields", () => { + const cipherView = createCipherView(CipherType.Identity); + cipherView.identity = new IdentityView(); + cipherView.identity.email = "example@bitwarden.com"; + cipherView.identity.phone = "123-456-7890"; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "email")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "phone")).toBe(true); + }); + + it("returns false when values are not populated", () => { + const cipherView = createCipherView(CipherType.Login); + + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "email")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "password")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "securityCode")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "username")).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true for copyable fields in a login cipher", () => { + const cipherListView = { + type: { login: { username: "testuser" } }, + copyableFields: ["LoginUsername", "LoginPassword"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "username")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "password")).toBe(true); + }); + + it("returns true for copyable fields in a card cipher", () => { + const cipherListView = { + type: { card: { brand: "MasterCard" } }, + copyableFields: ["CardNumber", "CardSecurityCode"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "cardNumber")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "securityCode")).toBe(true); + }); + + it("returns true for copyable fields in an sshKey ciphers", () => { + const cipherListView = { + type: "sshKey", + copyableFields: ["SshKey"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "privateKey")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "publicKey")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "keyFingerprint")).toBe(true); + }); + + it("returns true for copyable fields in an identity cipher", () => { + const cipherListView = { + type: "identity", + copyableFields: ["IdentityUsername", "IdentityEmail", "IdentityPhone"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "username")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "email")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "phone")).toBe(true); + }); + + it("returns false for when missing a field", () => { + const cipherListView = { + type: { login: {} }, + copyableFields: ["LoginUsername"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "password")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "phone")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "address")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "publicKey")).toBe(false); + }); + }); + }); + + describe("hasFido2Credentials", () => { + describe("CipherView", () => { + it("returns true when the login has FIDO2 credentials", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + cipherView.login.fido2Credentials = [new Fido2CredentialView()]; + + expect(CipherViewLikeUtils.hasFido2Credentials(cipherView)).toBe(true); + }); + + it("returns false when the login has no FIDO2 credentials", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + + expect(CipherViewLikeUtils.hasFido2Credentials(cipherView)).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true when the login has FIDO2 credentials", () => { + const cipherListView = { + type: { login: { fido2Credentials: [{ credentialId: "fido2-1" }] } }, + } as CipherListView; + + expect(CipherViewLikeUtils.hasFido2Credentials(cipherListView)).toBe(true); + }); + + it("returns false when the login has no FIDO2 credentials", () => { + const cipherListView = { type: { login: {} } } as CipherListView; + + expect(CipherViewLikeUtils.hasFido2Credentials(cipherListView)).toBe(false); + }); + }); + }); + + describe("decryptionFailure", () => { + it("returns true when the cipher has a decryption failure", () => { + const cipherView = createCipherView(); + cipherView.decryptionFailure = true; + + expect(CipherViewLikeUtils.decryptionFailure(cipherView)).toBe(true); + }); + + it("returns false when the cipher does not have a decryption failure", () => { + const cipherView = createCipherView(); + cipherView.decryptionFailure = false; + + expect(CipherViewLikeUtils.decryptionFailure(cipherView)).toBe(false); + }); + + it("returns false when the cipher is a CipherListView without decryptionFailure", () => { + const cipherListView = { type: "secureNote" } as CipherListView; + + expect(CipherViewLikeUtils.decryptionFailure(cipherListView)).toBe(false); + }); + }); +}); diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts new file mode 100644 index 00000000000..1c7a4382a04 --- /dev/null +++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts @@ -0,0 +1,301 @@ +import { + UriMatchStrategy, + UriMatchStrategySetting, +} from "@bitwarden/common/models/domain/domain-service"; +import { + CardListView, + CipherListView, + CopyableCipherFields, + LoginListView, + LoginUriView as LoginListUriView, +} from "@bitwarden/sdk-internal"; + +import { CipherType } from "../enums"; +import { Cipher } from "../models/domain/cipher"; +import { CardView } from "../models/view/card.view"; +import { CipherView } from "../models/view/cipher.view"; +import { LoginUriView } from "../models/view/login-uri.view"; +import { LoginView } from "../models/view/login.view"; + +/** + * Type union of {@link CipherView} and {@link CipherListView}. + */ +export type CipherViewLike = CipherView | CipherListView; + +/** + * Utility class for working with ciphers that can be either a {@link CipherView} or a {@link CipherListView}. + */ +export class CipherViewLikeUtils { + /** @returns true when the given cipher is an instance of {@link CipherListView}. */ + static isCipherListView = (cipher: CipherViewLike | Cipher): cipher is CipherListView => { + return typeof cipher.type === "object" || typeof cipher.type === "string"; + }; + + /** @returns The login object from the input cipher. If the cipher is not of type Login, returns null. */ + static getLogin = (cipher: CipherViewLike): LoginListView | LoginView | null => { + if (this.isCipherListView(cipher)) { + if (typeof cipher.type !== "object") { + return null; + } + + return "login" in cipher.type ? cipher.type.login : null; + } + + return cipher.type === CipherType.Login ? cipher.login : null; + }; + + /** @returns The first URI for a login cipher. If the cipher is not of type Login or has no associated URIs, returns null. */ + static uri = (cipher: CipherViewLike) => { + const login = this.getLogin(cipher); + if (!login) { + return null; + } + + if ("uri" in login) { + return login.uri; + } + + return login.uris?.length ? login.uris[0].uri : null; + }; + + /** @returns The login object from the input cipher. If the cipher is not of type Login, returns null. */ + static getCard = (cipher: CipherViewLike): CardListView | CardView | null => { + if (this.isCipherListView(cipher)) { + if (typeof cipher.type !== "object") { + return null; + } + + return "card" in cipher.type ? cipher.type.card : null; + } + + return cipher.type === CipherType.Card ? cipher.card : null; + }; + + /** @returns `true` when the cipher has been deleted, `false` otherwise. */ + static isDeleted = (cipher: CipherViewLike): boolean => { + if (this.isCipherListView(cipher)) { + return !!cipher.deletedDate; + } + + return cipher.isDeleted; + }; + + /** @returns `true` when the user can assign the cipher to a collection, `false` otherwise. */ + static canAssignToCollections = (cipher: CipherViewLike): boolean => { + if (this.isCipherListView(cipher)) { + if (!cipher.organizationId) { + return true; + } + + return cipher.edit && cipher.viewPassword; + } + + return cipher.canAssignToCollections; + }; + + /** + * Returns the type of the cipher. + * For consistency, when the given cipher is a {@link CipherListView} the {@link CipherType} equivalent will be returned. + */ + static getType = (cipher: CipherViewLike | Cipher): CipherType => { + if (!this.isCipherListView(cipher)) { + return cipher.type; + } + + // CipherListViewType is a string, so we need to map it to CipherType. + switch (true) { + case cipher.type === "secureNote": + return CipherType.SecureNote; + case cipher.type === "sshKey": + return CipherType.SshKey; + case cipher.type === "identity": + return CipherType.Identity; + case typeof cipher.type === "object" && "card" in cipher.type: + return CipherType.Card; + case typeof cipher.type === "object" && "login" in cipher.type: + return CipherType.Login; + default: + throw new Error(`Unknown cipher type: ${cipher.type}`); + } + }; + + /** @returns The subtitle of the cipher. */ + static subtitle = (cipher: CipherViewLike): string | undefined => { + if (!this.isCipherListView(cipher)) { + return cipher.subTitle; + } + + return cipher.subtitle; + }; + + /** @returns `true` when the cipher has attachments, false otherwise. */ + static hasAttachments = (cipher: CipherViewLike): boolean => { + if (this.isCipherListView(cipher)) { + return typeof cipher.attachments === "number" && cipher.attachments > 0; + } + + return cipher.hasAttachments; + }; + + /** + * @returns `true` when one of the URIs for the cipher can be launched. + * When a non-login cipher is passed, it will return false. + */ + static canLaunch = (cipher: CipherViewLike): boolean => { + const login = this.getLogin(cipher); + + if (!login) { + return false; + } + + return !!login.uris?.map((u) => toLoginUriView(u)).some((uri) => uri.canLaunch); + }; + + /** + * @returns The first launch-able URI for the cipher. + * When a non-login cipher is passed or none of the URLs, it will return undefined. + */ + static getLaunchUri = (cipher: CipherViewLike): string | undefined => { + const login = this.getLogin(cipher); + + if (!login) { + return undefined; + } + + return login.uris?.map((u) => toLoginUriView(u)).find((uri) => uri.canLaunch)?.launchUri; + }; + + /** + * @returns `true` when the `targetUri` matches for any URI on the cipher. + * Uses the existing logic from `LoginView.matchesUri` for both `CipherView` and `CipherListView` + */ + static matchesUri = ( + cipher: CipherViewLike, + targetUri: string, + equivalentDomains: Set, + defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain, + ): boolean => { + if (CipherViewLikeUtils.getType(cipher) !== CipherType.Login) { + return false; + } + + if (!this.isCipherListView(cipher)) { + return cipher.login.matchesUri(targetUri, equivalentDomains, defaultUriMatch); + } + + const login = this.getLogin(cipher); + if (!login?.uris?.length) { + return false; + } + + const loginUriViews = login.uris + .filter((u) => !!u.uri) + .map((u) => { + const view = new LoginUriView(); + view.match = u.match ?? defaultUriMatch; + view.uri = u.uri!; // above `filter` ensures `u.uri` is not null or undefined + return view; + }); + + return loginUriViews.some((uriView) => + uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch), + ); + }; + + /** @returns true when the `copyField` is populated on the given cipher. */ + static hasCopyableValue = (cipher: CipherViewLike, copyField: string): boolean => { + // `CipherListView` instances do not contain the values to be copied, but rather a list of copyable fields. + // When the copy action is performed on a `CipherListView`, the full cipher will need to be decrypted. + if (this.isCipherListView(cipher)) { + let _copyField = copyField; + + if (_copyField === "username" && this.getType(cipher) === CipherType.Login) { + _copyField = "usernameLogin"; + } else if (_copyField === "username" && this.getType(cipher) === CipherType.Identity) { + _copyField = "usernameIdentity"; + } + + return cipher.copyableFields.includes(copyActionToCopyableFieldMap[_copyField]); + } + + // When the full cipher is available, check the specific field + switch (copyField) { + case "username": + return !!cipher.login?.username || !!cipher.identity?.username; + case "password": + return !!cipher.login?.password; + case "totp": + return !!cipher.login?.totp; + case "cardNumber": + return !!cipher.card?.number; + case "securityCode": + return !!cipher.card?.code; + case "email": + return !!cipher.identity?.email; + case "phone": + return !!cipher.identity?.phone; + case "address": + return !!cipher.identity?.fullAddressForCopy; + case "secureNote": + return !!cipher.notes; + case "privateKey": + return !!cipher.sshKey?.privateKey; + case "publicKey": + return !!cipher.sshKey?.publicKey; + case "keyFingerprint": + return !!cipher.sshKey?.keyFingerprint; + default: + return false; + } + }; + + /** @returns true when the cipher has fido2 credentials */ + static hasFido2Credentials = (cipher: CipherViewLike): boolean => { + const login = this.getLogin(cipher); + + return !!login?.fido2Credentials?.length; + }; + + /** + * Returns the `decryptionFailure` property from the cipher when available. + * TODO: https://bitwarden.atlassian.net/browse/PM-22515 - alter for `CipherListView` if needed + */ + static decryptionFailure = (cipher: CipherViewLike): boolean => { + return "decryptionFailure" in cipher ? cipher.decryptionFailure : false; + }; +} + +/** + * Mapping between the generic copy actions and the specific fields in a `CipherViewLike`. + */ +const copyActionToCopyableFieldMap: Record = { + usernameLogin: "LoginUsername", + password: "LoginPassword", + totp: "LoginTotp", + cardNumber: "CardNumber", + securityCode: "CardSecurityCode", + usernameIdentity: "IdentityUsername", + email: "IdentityEmail", + phone: "IdentityPhone", + address: "IdentityAddress", + secureNote: "SecureNotes", + privateKey: "SshKey", + publicKey: "SshKey", + keyFingerprint: "SshKey", +}; + +/** Converts a `LoginListUriView` to a `LoginUriView`. */ +const toLoginUriView = (uri: LoginListUriView | LoginUriView): LoginUriView => { + if (uri instanceof LoginUriView) { + return uri; + } + + const loginUriView = new LoginUriView(); + if (uri.match) { + loginUriView.match = uri.match; + } + if (uri.uri) { + loginUriView.uri = uri.uri; + } + return loginUriView; +}; diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index bb749c2d0b1..355f3aef6eb 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -23,7 +23,7 @@ import { AnonLayoutBitwardenShield } from "../icon/logos"; import { SharedModule } from "../shared"; import { TypographyModule } from "../typography"; -export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl"; +export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; @Component({ selector: "auth-anon-layout", @@ -74,6 +74,8 @@ export class AnonLayoutComponent implements OnInit, OnChanges { return "tw-max-w-2xl"; case "3xl": return "tw-max-w-3xl"; + case "4xl": + return "tw-max-w-4xl"; } } diff --git a/libs/components/src/button/button.component.spec.ts b/libs/components/src/button/button.component.spec.ts index 1651b6cf12a..6ddbc172803 100644 --- a/libs/components/src/button/button.component.spec.ts +++ b/libs/components/src/button/button.component.spec.ts @@ -34,25 +34,23 @@ describe("Button", () => { expect(buttonDebugElement.nativeElement.disabled).toBeFalsy(); }); - it("should be aria-disabled and not html attribute disabled when disabled is true", () => { + it("should be disabled when disabled is true", () => { testAppComponent.disabled = true; fixture.detectChanges(); - expect(buttonDebugElement.attributes["aria-disabled"]).toBe("true"); - expect(buttonDebugElement.nativeElement.disabled).toBeFalsy(); + + expect(buttonDebugElement.nativeElement.disabled).toBeTruthy(); // Anchor tags cannot be disabled. }); - it("should be aria-disabled not html attribute disabled when attribute disabled is true", () => { - fixture.detectChanges(); - expect(disabledButtonDebugElement.attributes["aria-disabled"]).toBe("true"); - expect(disabledButtonDebugElement.nativeElement.disabled).toBeFalsy(); + it("should be disabled when attribute disabled is true", () => { + expect(disabledButtonDebugElement.nativeElement.disabled).toBeTruthy(); }); it("should be disabled when loading is true", () => { testAppComponent.loading = true; fixture.detectChanges(); - expect(buttonDebugElement.attributes["aria-disabled"]).toBe("true"); + expect(buttonDebugElement.nativeElement.disabled).toBeTruthy(); }); }); diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index a1b35608f25..635c269bd0f 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -1,20 +1,9 @@ import { NgClass } from "@angular/common"; -import { - HostBinding, - Component, - model, - computed, - input, - ElementRef, - inject, - Signal, - booleanAttribute, -} from "@angular/core"; +import { input, HostBinding, Component, model, computed, booleanAttribute } from "@angular/core"; import { toObservable, toSignal } from "@angular/core/rxjs-interop"; import { debounce, interval } from "rxjs"; import { ButtonLikeAbstraction, ButtonType, ButtonSize } from "../shared/button-like.abstraction"; -import { ariaDisableElement } from "../utils"; const focusRing = [ "focus-visible:tw-ring-2", @@ -62,7 +51,7 @@ const buttonStyles: Record = { providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }], imports: [NgClass], host: { - "[attr.aria-disabled]": "disabledAttr()", + "[attr.disabled]": "disabledAttr()", }, }) export class ButtonComponent implements ButtonLikeAbstraction { @@ -79,28 +68,27 @@ export class ButtonComponent implements ButtonLikeAbstraction { "focus:tw-outline-none", ] .concat(this.block() ? ["tw-w-full", "tw-block"] : ["tw-inline-block"]) + .concat(buttonStyles[this.buttonType() ?? "secondary"]) .concat( this.showDisabledStyles() || this.disabled() ? [ - "aria-disabled:!tw-bg-secondary-300", - "hover:tw-bg-secondary-300", - "aria-disabled:tw-border-secondary-300", - "hover:tw-border-secondary-300", - "aria-disabled:!tw-text-muted", - "hover:!tw-text-muted", - "aria-disabled:tw-cursor-not-allowed", - "hover:tw-no-underline", - "aria-disabled:tw-pointer-events-none", + "disabled:tw-bg-secondary-300", + "disabled:hover:tw-bg-secondary-300", + "disabled:tw-border-secondary-300", + "disabled:hover:tw-border-secondary-300", + "disabled:!tw-text-muted", + "disabled:hover:!tw-text-muted", + "disabled:tw-cursor-not-allowed", + "disabled:hover:tw-no-underline", ] : [], ) - .concat(buttonStyles[this.buttonType() ?? "secondary"]) .concat(buttonSizeStyles[this.size() || "default"]); } protected disabledAttr = computed(() => { const disabled = this.disabled() != null && this.disabled() !== false; - return disabled || this.loading() ? true : undefined; + return disabled || this.loading() ? true : null; }); /** @@ -139,10 +127,5 @@ export class ButtonComponent implements ButtonLikeAbstraction { toObservable(this.loading).pipe(debounce((isLoading) => interval(isLoading ? 75 : 0))), ); - readonly disabled = model(false); - private el = inject(ElementRef); - - constructor() { - ariaDisableElement(this.el.nativeElement, this.disabledAttr as Signal); - } + disabled = model(false); } diff --git a/libs/components/src/form-field/form-field.component.html b/libs/components/src/form-field/form-field.component.html index ccea0546f3a..c4fd018b3ba 100644 --- a/libs/components/src/form-field/form-field.component.html +++ b/libs/components/src/form-field/form-field.component.html @@ -46,7 +46,7 @@ = { const disabledStyles: Record = { contrast: [ - "aria-disabled:tw-opacity-60", - "aria-disabled:hover:tw-border-transparent", - "aria-disabled:hover:tw-bg-transparent", + "disabled:tw-opacity-60", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", ], main: [ - "aria-disabled:!tw-text-secondary-300", - "aria-disabled:hover:tw-border-transparent", - "aria-disabled:hover:tw-bg-transparent", + "disabled:!tw-text-secondary-300", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", ], muted: [ - "aria-disabled:!tw-text-secondary-300", - "aria-disabled:hover:tw-border-transparent", - "aria-disabled:hover:tw-bg-transparent", + "disabled:!tw-text-secondary-300", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", ], primary: [ - "aria-disabled:tw-opacity-60", - "aria-disabled:hover:tw-border-primary-600", - "aria-disabled:hover:tw-bg-primary-600", + "disabled:tw-opacity-60", + "disabled:hover:tw-border-primary-600", + "disabled:hover:tw-bg-primary-600", ], secondary: [ - "aria-disabled:tw-opacity-60", - "aria-disabled:hover:tw-border-text-muted", - "aria-disabled:hover:tw-bg-transparent", - "aria-disabled:hover:!tw-text-muted", + "disabled:tw-opacity-60", + "disabled:hover:tw-border-text-muted", + "disabled:hover:tw-bg-transparent", + "disabled:hover:!tw-text-muted", ], danger: [ - "aria-disabled:!tw-text-secondary-300", - "aria-disabled:hover:tw-border-transparent", - "aria-disabled:hover:tw-bg-transparent", - "aria-disabled:hover:!tw-text-secondary-300", + "disabled:!tw-text-secondary-300", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", + "disabled:hover:!tw-text-secondary-300", ], light: [ - "aria-disabled:tw-opacity-60", - "aria-disabled:hover:tw-border-transparent", - "aria-disabled:hover:tw-bg-transparent", + "disabled:tw-opacity-60", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", ], unstyled: [], }; @@ -173,7 +163,7 @@ const sizes: Record = { ], imports: [NgClass], host: { - "[attr.aria-disabled]": "disabledAttr()", + "[attr.disabled]": "disabledAttr()", }, }) export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { @@ -245,10 +235,5 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE return this.elementRef.nativeElement; } - private elementRef = inject(ElementRef); - - constructor() { - const element = this.elementRef.nativeElement; - ariaDisableElement(element, this.disabledAttr as Signal); - } + constructor(private elementRef: ElementRef) {} } diff --git a/libs/components/src/link/link.directive.ts b/libs/components/src/link/link.directive.ts index aced89fc7b3..f2eb44bc3a4 100644 --- a/libs/components/src/link/link.directive.ts +++ b/libs/components/src/link/link.directive.ts @@ -1,6 +1,4 @@ -import { HostBinding, Directive, inject, ElementRef, input, booleanAttribute } from "@angular/core"; - -import { ariaDisableElement } from "../utils"; +import { input, HostBinding, Directive } from "@angular/core"; export type LinkType = "primary" | "secondary" | "contrast" | "light"; @@ -60,11 +58,6 @@ const commonStyles = [ "before:tw-transition", "focus-visible:before:tw-ring-2", "focus-visible:tw-z-10", - "aria-disabled:tw-no-underline", - "aria-disabled:tw-pointer-events-none", - "aria-disabled:!tw-text-secondary-300", - "aria-disabled:hover:!tw-text-secondary-300", - "aria-disabled:hover:tw-no-underline", ]; @Directive() @@ -95,19 +88,9 @@ export class AnchorLinkDirective extends LinkDirective { selector: "button[bitLink]", }) export class ButtonLinkDirective extends LinkDirective { - private el = inject(ElementRef); - - disabled = input(false, { transform: booleanAttribute }); - @HostBinding("class") get classList() { return ["before:-tw-inset-y-[0.25rem]"] .concat(commonStyles) .concat(linkStyles[this.linkType()] ?? []); } - - constructor() { - super(); - - ariaDisableElement(this.el.nativeElement, this.disabled); - } } diff --git a/libs/components/src/utils/aria-disable-element.ts b/libs/components/src/utils/aria-disable-element.ts deleted file mode 100644 index f7e02f2cdd1..00000000000 --- a/libs/components/src/utils/aria-disable-element.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Signal, effect } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { fromEvent } from "rxjs"; - -/** - * a11y helper util used to `aria-disable` elements as opposed to using the HTML `disabled` attr. - * - Removes HTML `disabled` attr and replaces it with `aria-disabled="true"` - * - Captures click events and prevents them from propagating - */ -export function ariaDisableElement(element: HTMLElement, isDisabled: Signal) { - effect(() => { - if (element.hasAttribute("disabled") || isDisabled()) { - // Remove native disabled and set aria-disabled. Capture click event - element.removeAttribute("disabled"); - - element.setAttribute("aria-disabled", "true"); - } - }); - - fromEvent(element, "click") - .pipe(takeUntilDestroyed()) - .subscribe((event: Event) => { - if (isDisabled()) { - event.stopPropagation(); - event.preventDefault(); - return false; - } - }); -} diff --git a/libs/components/src/utils/index.ts b/libs/components/src/utils/index.ts index 91fa71cf0e0..afadd6b3b41 100644 --- a/libs/components/src/utils/index.ts +++ b/libs/components/src/utils/index.ts @@ -1,3 +1,2 @@ -export * from "./aria-disable-element"; export * from "./function-to-observable"; export * from "./i18n-mock.service"; 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 3d5951a5ac4..9f370c88fa9 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -577,6 +577,9 @@ export class LockComponent implements OnInit, OnDestroy { throw new Error("No active user."); } + // Add a mark to indicate that the user has unlocked their vault. A good starting point for measuring unlock performance. + this.logService.mark("Vault unlocked"); + await this.keyService.setUserKey(key, this.activeAccount.id); // Now that we have a decrypted user key in memory, we can check if we diff --git a/libs/logging/src/console-log.service.ts b/libs/logging/src/console-log.service.ts index 3a4ffe9ead1..29246368cf7 100644 --- a/libs/logging/src/console-log.service.ts +++ b/libs/logging/src/console-log.service.ts @@ -54,4 +54,43 @@ export class ConsoleLogService implements LogService { break; } } + + measure( + start: DOMHighResTimeStamp, + trackGroup: string, + track: string, + name?: string, + properties?: [string, any][], + ): PerformanceMeasure { + const measureName = `[${track}]: ${name}`; + + const measure = performance.measure(measureName, { + start: start, + detail: { + devtools: { + dataType: "track-entry", + track, + trackGroup, + properties, + }, + }, + }); + + this.info(`${measureName} took ${measure.duration}`, properties); + return measure; + } + + mark(name: string): PerformanceMark { + const mark = performance.mark(name, { + detail: { + devtools: { + dataType: "marker", + }, + }, + }); + + this.info(mark.name, new Date().toISOString()); + + return mark; + } } diff --git a/libs/logging/src/log.service.ts b/libs/logging/src/log.service.ts index a63ad47c07e..ce391723bf9 100644 --- a/libs/logging/src/log.service.ts +++ b/libs/logging/src/log.service.ts @@ -6,4 +6,28 @@ export abstract class LogService { abstract warning(message?: any, ...optionalParams: any[]): void; abstract error(message?: any, ...optionalParams: any[]): void; abstract write(level: LogLevel, message?: any, ...optionalParams: any[]): void; + + /** + * Helper wrapper around `performance.measure` to log a measurement. Should also debug-log the data. + * + * @param start Start time of the measurement. + * @param trackGroup A track-group for the measurement, should generally be the team owning the domain. + * @param track A track for the measurement, should generally be the class name. + * @param measureName A descriptive name for the measurement. + * @param properties Additional properties to include. + */ + abstract measure( + start: DOMHighResTimeStamp, + trackGroup: string, + track: string, + measureName: string, + properties?: [string, any][], + ): PerformanceMeasure; + + /** + * Helper wrapper around `performance.mark` to log a mark. Should also debug-log the data. + * + * @param name Name of the mark to create. + */ + abstract mark(name: string): PerformanceMark; } diff --git a/libs/vault/src/components/copy-cipher-field.directive.spec.ts b/libs/vault/src/components/copy-cipher-field.directive.spec.ts index 0847e7147a9..a3650c68c9b 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.spec.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.spec.ts @@ -1,3 +1,9 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/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 { BitIconButtonComponent, MenuItemDirective } from "@bitwarden/components"; import { CopyCipherFieldService } from "@bitwarden/vault"; @@ -9,23 +15,31 @@ describe("CopyCipherFieldDirective", () => { copy: jest.fn().mockResolvedValue(null), totpAllowed: jest.fn().mockResolvedValue(true), }; + let mockAccountService: AccountService; + let mockCipherService: CipherService; let copyCipherFieldDirective: CopyCipherFieldDirective; beforeEach(() => { copyFieldService.copy.mockClear(); copyFieldService.totpAllowed.mockClear(); + mockAccountService = mock(); + mockAccountService.activeAccount$ = of({ id: "test-account-id" } as Account); + mockCipherService = mock(); copyCipherFieldDirective = new CopyCipherFieldDirective( copyFieldService as unknown as CopyCipherFieldService, + mockAccountService, + mockCipherService, ); copyCipherFieldDirective.cipher = new CipherView(); + copyCipherFieldDirective.cipher.type = CipherType.Login; }); describe("disabled state", () => { it("should be enabled when the field is available", async () => { copyCipherFieldDirective.action = "username"; - copyCipherFieldDirective.cipher.login.username = "test-username"; + (copyCipherFieldDirective.cipher as CipherView).login.username = "test-username"; await copyCipherFieldDirective.ngOnChanges(); @@ -35,6 +49,7 @@ describe("CopyCipherFieldDirective", () => { it("should be disabled when the field is not available", async () => { // create empty cipher copyCipherFieldDirective.cipher = new CipherView(); + copyCipherFieldDirective.cipher.type = CipherType.Login; copyCipherFieldDirective.action = "username"; @@ -52,11 +67,15 @@ describe("CopyCipherFieldDirective", () => { copyCipherFieldDirective = new CopyCipherFieldDirective( copyFieldService as unknown as CopyCipherFieldService, + mockAccountService, + mockCipherService, undefined, iconButton as unknown as BitIconButtonComponent, ); copyCipherFieldDirective.action = "password"; + copyCipherFieldDirective.cipher = new CipherView(); + copyCipherFieldDirective.cipher.type = CipherType.Login; await copyCipherFieldDirective.ngOnChanges(); @@ -70,6 +89,8 @@ describe("CopyCipherFieldDirective", () => { copyCipherFieldDirective = new CopyCipherFieldDirective( copyFieldService as unknown as CopyCipherFieldService, + mockAccountService, + mockCipherService, menuItemDirective as unknown as MenuItemDirective, ); @@ -83,9 +104,11 @@ describe("CopyCipherFieldDirective", () => { describe("login", () => { beforeEach(() => { - copyCipherFieldDirective.cipher.login.username = "test-username"; - copyCipherFieldDirective.cipher.login.password = "test-password"; - copyCipherFieldDirective.cipher.login.totp = "test-totp"; + const cipher = copyCipherFieldDirective.cipher as CipherView; + cipher.type = CipherType.Login; + cipher.login.username = "test-username"; + cipher.login.password = "test-password"; + cipher.login.totp = "test-totp"; }); it.each([ @@ -107,10 +130,12 @@ describe("CopyCipherFieldDirective", () => { describe("identity", () => { beforeEach(() => { - copyCipherFieldDirective.cipher.identity.username = "test-username"; - copyCipherFieldDirective.cipher.identity.email = "test-email"; - copyCipherFieldDirective.cipher.identity.phone = "test-phone"; - copyCipherFieldDirective.cipher.identity.address1 = "test-address-1"; + const cipher = copyCipherFieldDirective.cipher as CipherView; + cipher.type = CipherType.Identity; + cipher.identity.username = "test-username"; + cipher.identity.email = "test-email"; + cipher.identity.phone = "test-phone"; + cipher.identity.address1 = "test-address-1"; }); it.each([ @@ -133,8 +158,10 @@ describe("CopyCipherFieldDirective", () => { describe("card", () => { beforeEach(() => { - copyCipherFieldDirective.cipher.card.number = "test-card-number"; - copyCipherFieldDirective.cipher.card.code = "test-card-code"; + const cipher = copyCipherFieldDirective.cipher as CipherView; + cipher.type = CipherType.Card; + cipher.card.number = "test-card-number"; + cipher.card.code = "test-card-code"; }); it.each([ @@ -155,7 +182,9 @@ describe("CopyCipherFieldDirective", () => { describe("secure note", () => { beforeEach(() => { - copyCipherFieldDirective.cipher.notes = "test-secure-note"; + const cipher = copyCipherFieldDirective.cipher as CipherView; + cipher.type = CipherType.SecureNote; + cipher.notes = "test-secure-note"; }); it("copies secure note field to clipboard", async () => { @@ -173,9 +202,11 @@ describe("CopyCipherFieldDirective", () => { describe("ssh key", () => { beforeEach(() => { - copyCipherFieldDirective.cipher.sshKey.privateKey = "test-private-key"; - copyCipherFieldDirective.cipher.sshKey.publicKey = "test-public-key"; - copyCipherFieldDirective.cipher.sshKey.keyFingerprint = "test-key-fingerprint"; + const cipher = copyCipherFieldDirective.cipher as CipherView; + cipher.type = CipherType.SshKey; + cipher.sshKey.privateKey = "test-private-key"; + cipher.sshKey.publicKey = "test-public-key"; + cipher.sshKey.keyFingerprint = "test-key-fingerprint"; }); it.each([ diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts index 0ab7400a6dd..59ad8bf38e8 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.ts @@ -1,6 +1,14 @@ import { Directive, HostBinding, HostListener, Input, OnChanges, Optional } from "@angular/core"; +import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { MenuItemDirective, BitIconButtonComponent } from "@bitwarden/components"; import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault"; @@ -27,10 +35,12 @@ export class CopyCipherFieldDirective implements OnChanges { }) action!: Exclude; - @Input({ required: true }) cipher!: CipherView; + @Input({ required: true }) cipher!: CipherViewLike; constructor( private copyCipherFieldService: CopyCipherFieldService, + private accountService: AccountService, + private cipherService: CipherService, @Optional() private menuItemDirective?: MenuItemDirective, @Optional() private iconButtonComponent?: BitIconButtonComponent, ) {} @@ -49,7 +59,7 @@ export class CopyCipherFieldDirective implements OnChanges { @HostListener("click") async copy() { - const value = this.getValueToCopy(); + const value = await this.getValueToCopy(); await this.copyCipherFieldService.copy(value ?? "", this.action, this.cipher); } @@ -60,7 +70,7 @@ export class CopyCipherFieldDirective implements OnChanges { private async updateDisabledState() { this.disabled = !this.cipher || - !this.getValueToCopy() || + !this.hasValueToCopy() || (this.action === "totp" && !(await this.copyCipherFieldService.totpAllowed(this.cipher))) ? true : null; @@ -76,32 +86,51 @@ export class CopyCipherFieldDirective implements OnChanges { } } - private getValueToCopy() { + /** Returns `true` when the cipher has the associated value as populated. */ + private hasValueToCopy() { + return CipherViewLikeUtils.hasCopyableValue(this.cipher, this.action); + } + + /** Returns the value of the cipher to be copied. */ + private async getValueToCopy() { + let _cipher: CipherView; + + if (CipherViewLikeUtils.isCipherListView(this.cipher)) { + // When the cipher is of type `CipherListView`, the full cipher needs to be decrypted + const activeAccountId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getUserId), + ); + const encryptedCipher = await this.cipherService.get(this.cipher.id!, activeAccountId); + _cipher = await this.cipherService.decrypt(encryptedCipher, activeAccountId); + } else { + _cipher = this.cipher; + } + switch (this.action) { case "username": - return this.cipher.login?.username || this.cipher.identity?.username; + return _cipher.login?.username || _cipher.identity?.username; case "password": - return this.cipher.login?.password; + return _cipher.login?.password; case "totp": - return this.cipher.login?.totp; + return _cipher.login?.totp; case "cardNumber": - return this.cipher.card?.number; + return _cipher.card?.number; case "securityCode": - return this.cipher.card?.code; + return _cipher.card?.code; case "email": - return this.cipher.identity?.email; + return _cipher.identity?.email; case "phone": - return this.cipher.identity?.phone; + return _cipher.identity?.phone; case "address": - return this.cipher.identity?.fullAddressForCopy; + return _cipher.identity?.fullAddressForCopy; case "secureNote": - return this.cipher.notes; + return _cipher.notes; case "privateKey": - return this.cipher.sshKey?.privateKey; + return _cipher.sshKey?.privateKey; case "publicKey": - return this.cipher.sshKey?.publicKey; + return _cipher.sshKey?.publicKey; case "keyFingerprint": - return this.cipher.sshKey?.keyFingerprint; + return _cipher.sshKey?.keyFingerprint; default: return null; } diff --git a/libs/vault/src/services/copy-cipher-field.service.spec.ts b/libs/vault/src/services/copy-cipher-field.service.spec.ts index 5b038376aee..3bd8f911f3e 100644 --- a/libs/vault/src/services/copy-cipher-field.service.spec.ts +++ b/libs/vault/src/services/copy-cipher-field.service.spec.ts @@ -8,7 +8,7 @@ import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; -import { CipherRepromptType } from "@bitwarden/common/vault/enums"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { ToastService } from "@bitwarden/components"; @@ -128,6 +128,7 @@ describe("CopyCipherFieldService", () => { describe("totp", () => { beforeEach(() => { actionType = "totp"; + cipher.type = CipherType.Login; cipher.login = new LoginView(); cipher.login.totp = "secret-totp"; cipher.reprompt = CipherRepromptType.None; diff --git a/libs/vault/src/services/copy-cipher-field.service.ts b/libs/vault/src/services/copy-cipher-field.service.ts index 3f94b27cef8..606614f2143 100644 --- a/libs/vault/src/services/copy-cipher-field.service.ts +++ b/libs/vault/src/services/copy-cipher-field.service.ts @@ -9,7 +9,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherRepromptType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { ToastService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -103,7 +106,7 @@ export class CopyCipherFieldService { async copy( valueToCopy: string, actionType: CopyAction, - cipher: CipherView, + cipher: CipherViewLike, skipReprompt: boolean = false, ): Promise { const action = CopyActions[actionType]; @@ -153,13 +156,16 @@ export class CopyCipherFieldService { /** * Determines if TOTP generation is allowed for a cipher and user. */ - async totpAllowed(cipher: CipherView): Promise { + async totpAllowed(cipher: CipherViewLike): Promise { const activeAccount = await firstValueFrom(this.accountService.activeAccount$); if (!activeAccount?.id) { return false; } + + const login = CipherViewLikeUtils.getLogin(cipher); + return ( - (cipher?.login?.hasTotp ?? false) && + !!login?.totp && (cipher.organizationUseTotp || (await firstValueFrom( this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeAccount.id), diff --git a/libs/vault/src/services/password-reprompt.service.ts b/libs/vault/src/services/password-reprompt.service.ts index 6583d0787fc..e6a6b20b320 100644 --- a/libs/vault/src/services/password-reprompt.service.ts +++ b/libs/vault/src/services/password-reprompt.service.ts @@ -4,7 +4,7 @@ import { firstValueFrom, lastValueFrom } from "rxjs"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherRepromptType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptComponent } from "../components/password-reprompt.component"; @@ -28,7 +28,7 @@ export class PasswordRepromptService { return ["TOTP", "Password", "H_Field", "Card Number", "Security Code"]; } - async passwordRepromptCheck(cipher: CipherView) { + async passwordRepromptCheck(cipher: CipherViewLike) { if (cipher.reprompt === CipherRepromptType.None) { return true; }
{{ "aDeviceIs" | i18n }}
+ {{ "deviceListDescriptionTemp" | i18n }} +