1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-07 04:03:29 +00:00

Merge branch 'main' into km/new-mp-service-api

This commit is contained in:
Bernd Schoolmann
2025-07-18 11:54:27 +02:00
committed by GitHub
113 changed files with 3305 additions and 983 deletions

View File

@@ -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

View File

@@ -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"
}
}
},

View File

@@ -102,6 +102,18 @@
</bit-card>
</bit-section>
<bit-section *ngIf="extensionLoginApprovalFlagEnabled">
<bit-section-header>
<h2 bitTypography="h6">{{ "manageDevices" | i18n }}</h2>
</bit-section-header>
<bit-item>
<button bit-item-content type="button" appStopClick routerLink="/device-management">
{{ "devices" | i18n }}
<i slot="end" class="bwi bwi-chevron-right" aria-hidden="true"></i>
</button>
</bit-item>
</bit-section>
<bit-section disableMargin>
<bit-section-header>
<h2 bitTypography="h6">{{ "otherOptions" | i18n }}</h2>

View File

@@ -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<boolean> = 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 () => {

View File

@@ -0,0 +1,11 @@
<popup-page>
<popup-header slot="header" pageTitle="{{ 'devices' | i18n }}" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
</popup-header>
<div class="tw-bg-background-alt">
<auth-device-management></auth-device-management>
</div>
</popup-page>

View File

@@ -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 {}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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: {

View File

@@ -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({

View File

@@ -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<PopupCipherView[]> =
protected autofillCiphers$: Observable<PopupCipherViewLike[]> =
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,
),
);

View File

@@ -1,4 +1,4 @@
<ng-container *ngIf="cipher.type === CipherType.Login">
<ng-container *ngIf="CipherViewLikeUtils.getType(cipher) === CipherType.Login">
<ng-container *ngIf="showQuickCopyActions$ | async; else loginCopyMenu">
<bit-item-action>
<button
@@ -36,15 +36,15 @@
<ng-template #loginCopyMenu>
<bit-item-action>
<button
*ngIf="singleCopiableLogin"
*ngIf="singleCopyableLogin"
type="button"
bitIconButton="bwi-clone"
size="small"
[appA11yTitle]="singleCopiableLogin.key"
[appCopyField]="$any(singleCopiableLogin.field)"
[appA11yTitle]="'copyFieldCipherName' | i18n: singleCopyableLogin.key : cipher.name"
[appCopyField]="singleCopyableLogin.field"
[cipher]="cipher"
></button>
<ng-container *ngIf="!singleCopiableLogin">
<ng-container *ngIf="!singleCopyableLogin">
<button
type="button"
bitIconButton="bwi-clone"
@@ -77,7 +77,7 @@
</ng-template>
</ng-container>
<ng-container *ngIf="cipher.type === CipherType.Card">
<ng-container *ngIf="CipherViewLikeUtils.getType(cipher) === CipherType.Card">
<ng-container *ngIf="showQuickCopyActions$ | async; else cardCopyMenu">
<bit-item-action>
<button
@@ -103,16 +103,16 @@
<ng-template #cardCopyMenu>
<bit-item-action>
<button
*ngIf="singleCopiableCard"
*ngIf="singleCopyableCard"
type="button"
bitIconButton="bwi-clone"
size="small"
[appA11yTitle]="'copyFieldValue' | i18n: singleCopiableCard.key : singleCopiableCard.value"
[appCopyClick]="singleCopiableCard.value"
[valueLabel]="singleCopiableCard.key"
[appA11yTitle]="'copyFieldCipherName' | i18n: singleCopyableCard.key : cipher.name"
[appCopyField]="singleCopyableCard.field"
[cipher]="cipher"
showToast
></button>
<ng-container *ngIf="!singleCopiableCard">
<ng-container *ngIf="!singleCopyableCard">
<button
type="button"
bitIconButton="bwi-clone"
@@ -136,20 +136,18 @@
</ng-template>
</ng-container>
<bit-item-action *ngIf="cipher.type === CipherType.Identity">
<bit-item-action *ngIf="CipherViewLikeUtils.getType(cipher) === CipherType.Identity">
<button
*ngIf="singleCopiableIdentity"
*ngIf="singleCopyableIdentity"
type="button"
bitIconButton="bwi-clone"
size="small"
[appA11yTitle]="
'copyFieldValue' | i18n: singleCopiableIdentity.key : singleCopiableIdentity.value
"
[appCopyClick]="singleCopiableIdentity.value"
[valueLabel]="singleCopiableIdentity.key"
[appA11yTitle]="'copyFieldCipherName' | i18n: singleCopyableIdentity.key : cipher.name"
[appCopyField]="singleCopyableIdentity.field"
[cipher]="cipher"
showToast
></button>
<ng-container *ngIf="!singleCopiableIdentity">
<ng-container *ngIf="!singleCopyableIdentity">
<button
type="button"
bitIconButton="bwi-clone"
@@ -177,7 +175,7 @@
</ng-container>
</bit-item-action>
<bit-item-action *ngIf="cipher.type === CipherType.SecureNote">
<bit-item-action *ngIf="CipherViewLikeUtils.getType(cipher) === CipherType.SecureNote">
<button
type="button"
bitIconButton="bwi-clone"
@@ -190,7 +188,7 @@
></button>
</bit-item-action>
<bit-item-action *ngIf="cipher.type === CipherType.SshKey">
<bit-item-action *ngIf="CipherViewLikeUtils.getType(cipher) === CipherType.SshKey">
<button
type="button"
bitIconButton="bwi-clone"

View File

@@ -1,21 +1,24 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, Input, inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
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 { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components";
import { CopyCipherFieldDirective } from "@bitwarden/vault";
import { CopyableCipherFields } from "@bitwarden/sdk-internal";
import { CopyAction, CopyCipherFieldDirective } from "@bitwarden/vault";
import { VaultPopupCopyButtonsService } from "../../../services/vault-popup-copy-buttons.service";
type CipherItem = {
value: string;
/** Translation key for the respective value */
key: string;
field?: string;
/** Property key on `CipherView` to retrieve the copy value */
field: CopyAction;
};
@Component({
@@ -32,91 +35,155 @@ type CipherItem = {
})
export class ItemCopyActionsComponent {
protected showQuickCopyActions$ = inject(VaultPopupCopyButtonsService).showQuickCopyActions$;
@Input({ required: true }) cipher!: CipherViewLike;
@Input() cipher: CipherView;
protected CipherViewLikeUtils = CipherViewLikeUtils;
protected CipherType = CipherType;
get hasLoginValues() {
return (
!!this.cipher.login.hasTotp || !!this.cipher.login.password || !!this.cipher.login.username
);
}
/*
* singleCopiableLogin uses appCopyField instead of appCopyClick. This allows for the TOTP
* singleCopyableLogin uses appCopyField instead of appCopyClick. This allows for the TOTP
* code to be copied correctly. See #14167
*/
get singleCopiableLogin() {
get singleCopyableLogin() {
const loginItems: CipherItem[] = [
{ value: this.cipher.login.username, key: "copyUsername", field: "username" },
{ value: this.cipher.login.password, key: "copyPassword", field: "password" },
{ value: this.cipher.login.totp, key: "copyVerificationCode", field: "totp" },
{ key: "copyUsername", field: "username" },
{ key: "copyPassword", field: "password" },
{ key: "copyVerificationCode", field: "totp" },
];
// If both the password and username are visible but the password is hidden, return the username
if (!this.cipher.viewPassword && this.cipher.login.username && this.cipher.login.password) {
if (
!this.cipher.viewPassword &&
CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") &&
CipherViewLikeUtils.hasCopyableValue(this.cipher, "password")
) {
return {
value: this.cipher.login.username,
key: this.i18nService.t("copyUsername"),
field: "username",
};
}
return this.findSingleCopiableItem(loginItems);
return this.findSingleCopyableItem(loginItems);
}
get singleCopiableCard() {
get singleCopyableCard() {
const cardItems: CipherItem[] = [
{ value: this.cipher.card.code, key: "code" },
{ value: this.cipher.card.number, key: "number" },
{ key: "securityCode", field: "securityCode" },
{ key: "cardNumber", field: "cardNumber" },
];
return this.findSingleCopiableItem(cardItems);
return this.findSingleCopyableItem(cardItems);
}
get singleCopiableIdentity() {
get singleCopyableIdentity() {
const identityItems: CipherItem[] = [
{ value: this.cipher.identity.fullAddressForCopy, key: "address" },
{ value: this.cipher.identity.email, key: "email" },
{ value: this.cipher.identity.username, key: "username" },
{ value: this.cipher.identity.phone, key: "phone" },
{ key: "address", field: "address" },
{ key: "email", field: "email" },
{ key: "username", field: "username" },
{ key: "phone", field: "phone" },
];
return this.findSingleCopiableItem(identityItems);
return this.findSingleCopyableItem(identityItems);
}
/*
* Given a list of CipherItems, if there is only one item with a value,
* return it with the translated key. Otherwise return null
*/
findSingleCopiableItem(items: CipherItem[]): CipherItem | null {
const itemsWithValue = items.filter(({ value }) => !!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;
}
}

View File

@@ -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"
></button>
<bit-menu #moreOptions>

View File

@@ -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<CipherView>(undefined);
private _cipher$ = new BehaviorSubject<CipherViewLike>(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,
});
}

View File

@@ -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", () => {

View File

@@ -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)"
></i>
<i
*ngIf="cipher.hasAttachments"
*ngIf="CipherViewLikeUtils.hasAttachments(cipher)"
class="bwi bwi-paperclip bwi-sm"
[appA11yTitle]="'attachments' | i18n"
></i>
<span slot="secondary">{{ cipher.subTitle }}</span>
<span slot="secondary">{{ CipherViewLikeUtils.subtitle(cipher) }}</span>
</button>
<ng-container slot="end">
@@ -134,7 +135,7 @@
{{ "fill" | i18n }}
</button>
</bit-item-action>
<bit-item-action *ngIf="!showAutofillButton() && cipher.canLaunch">
<bit-item-action *ngIf="!showAutofillButton() && CipherViewLikeUtils.canLaunch(cipher)">
<button
type="button"
bitIconButton="bwi-external-link"

View File

@@ -26,7 +26,10 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { CipherId } from "@bitwarden/common/types/guid";
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 {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import {
BadgeModule,
ButtonModule,
@@ -54,7 +57,7 @@ import {
VaultPopupSectionService,
PopupSectionOpen,
} from "../../../services/vault-popup-section.service";
import { PopupCipherView } from "../../../views/popup-cipher.view";
import { PopupCipherViewLike } from "../../../views/popup-cipher.view";
import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component";
import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component";
@@ -84,6 +87,7 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options
export class VaultListItemsContainerComponent implements AfterViewInit {
private compactModeService = inject(CompactModeService);
private vaultPopupSectionService = inject(VaultPopupSectionService);
protected CipherViewLikeUtils = CipherViewLikeUtils;
@ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort!: CdkVirtualScrollViewport;
@ViewChild(DisclosureComponent) disclosure!: DisclosureComponent;
@@ -125,7 +129,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
*/
private viewCipherTimeout?: number;
ciphers = input<PopupCipherView[]>([]);
ciphers = input<PopupCipherViewLike[]>([]);
/**
* 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<string, PopupCipherView[]> = {};
const groups: Record<string, PopupCipherViewLike[]> = {};
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<string | undefined>(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,
);
}

View File

@@ -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<CipherView[]>(cipherList);
cipherServiceMock.cipherListViews$.mockReturnValue(cipherList$.asObservable());
ciphersSubject = new BehaviorSubject<Record<CipherId, CipherData>>({});
localDataSubject = new BehaviorSubject<Record<CipherId, LocalData>>({});
@@ -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$);

View File

@@ -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<CipherView[]> = 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<CipherViewLike[]> =
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<PopupCipherView[]> = this._allDecryptedCiphers$.pipe(
private _activeCipherList$: Observable<PopupCipherViewLike[]> = 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<PopupCipherView[]> = combineLatest([
private _filteredCipherList$: Observable<PopupCipherViewLike[]> = 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<PopupCipherView[]> = combineLatest([
autoFillCiphers$: Observable<PopupCipherViewLike[]> = 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<PopupCipherView[]> = this.autoFillCiphers$.pipe(
favoriteCiphers$: Observable<PopupCipherViewLike[]> = 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<PopupCipherView[]> = this.favoriteCiphers$.pipe(
remainingCiphers$: Observable<PopupCipherViewLike[]> = 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<PopupCipherView[]> = this._allDecryptedCiphers$.pipe(
deletedCiphers$: Observable<PopupCipherViewLike[]> = 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<CipherType, number>;
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;
}

View File

@@ -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<Organization[]>([]);
let folderViews$ = new BehaviorSubject([]);
const cipherViews$ = new BehaviorSubject({});
const cipherListViews$ = new BehaviorSubject({});
let decryptedCollections$ = new BehaviorSubject<CollectionView[]>([]);
const policyAppliesToUser$ = new BehaviorSubject<boolean>(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<CachedFilterState> } {
): {
service: VaultPopupListFiltersService;
cachedSignal: WritableSignal<CachedFilterState>;
} {
const seededMemberOrganizations$ = new BehaviorSubject<Organization[]>(organizations);
const seededCollections$ = new BehaviorSubject<CollectionView[]>(collections);
const seededFolderViews$ = new BehaviorSubject<FolderView[]>(folderViews);
@@ -744,7 +771,7 @@ function createSeededVaultPopupListFiltersService(
} as any;
const cipherServiceMock = {
cipherViews$: () => new BehaviorSubject({}),
cipherListViews$: () => new BehaviorSubject({}),
} as any;
const i18nServiceMock = {

View File

@@ -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<boolean>(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<ChipSelectOption<FolderView>[]> = 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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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"
},

View File

@@ -1,6 +1,6 @@
<ng-container *ngIf="show">
<ng-container [ngSwitch]="displayMode">
<ng-container *ngSwitchCase="'personalOwnershipPolicy'">
<ng-container *ngSwitchCase="'organizationDataOwnershipPolicy'">
<div class="filter-heading" [ngClass]="{ active: !hasActiveFilter }">
<button
type="button"

View File

@@ -34,7 +34,7 @@
></i>
<span class="sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="c.hasAttachments">
<ng-container *ngIf="CipherViewLikeUtils.hasAttachments(c)">
<i
class="bwi bwi-paperclip text-muted"
title="{{ 'attachments' | i18n }}"
@@ -44,7 +44,9 @@
</ng-container>
</span>
</span>
<span *ngIf="c.subTitle" class="detail">{{ c.subTitle }}</span>
<span *ngIf="CipherViewLikeUtils.subtitle(c)" class="detail">{{
CipherViewLikeUtils.subtitle(c)
}}</span>
</div>
</button>
</div>

View File

@@ -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<C extends CipherViewLike> extends BaseVaultItemsComponent<C> {
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!;
}
}

View File

@@ -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<C extends CipherViewLike>
implements OnInit, OnDestroy, CopyClickListener
{
@ViewChild(VaultItemsV2Component, { static: true })
vaultItemsComponent: VaultItemsV2Component | null = null;
vaultItemsComponent: VaultItemsV2Component<C> | 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"),

View File

@@ -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<CipherView>(
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<CipherView>) {
this.processingEvent = true;
try {

View File

@@ -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<DeviceManagementComponent>;
describe("DeviceManagementOldComponent", () => {
let fixture: ComponentFixture<DeviceManagementOldComponent>;
let messageSubject: Subject<Message>;
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);
});

View File

@@ -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<DeviceTableData>();
protected currentDevice: DeviceView | undefined;
protected loading = true;

View File

@@ -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" },
},
}),
],
},
];

View File

@@ -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({

View File

@@ -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)],

View File

@@ -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)],

View File

@@ -610,7 +610,7 @@ const routes: Routes = [
data: {
hideCardWrapper: true,
hideIcon: true,
maxWidth: "3xl",
maxWidth: "4xl",
} satisfies AnonLayoutWrapperData,
children: [
{

View File

@@ -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,

View File

@@ -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 }}
</button>
`,
standalone: false,
imports: [JslibModule, BadgeModule],
})
export class PremiumBadgeComponent {
constructor(private messagingService: MessagingService) {}

View File

@@ -46,10 +46,10 @@
The first video is relatively positioned to force the layout and spacing of the videos.
-->
<div
class="tw-mx-auto tw-w-[15rem] tw-mb-8 tw-relative md:tw-grid md:tw-gap-10 md:tw-w-auto md:tw-grid-rows-1 md:tw-grid-cols-3 md:tw-justify-center md:tw-justify-items-center"
class="tw-mx-auto tw-w-[15rem] tw-mb-8 tw-relative md:tw-grid md:tw-gap-10 md:tw-w-auto md:tw-grid-rows-1 md:tw-grid-cols-3 md:tw-justify-center md:tw-justify-items-center xl:tw-gap-12"
[attr.aria-label]="'setupExtensionContentAlt' | i18n"
>
<div class="tw-relative tw-w-[15rem] tw-max-w-full tw-aspect-[0.807]">
<div [ngClass]="videoContainerClass">
<div
*ngIf="!allVideosLoaded"
class="tw-animate-pulse tw-bg-secondary-300 tw-size-full tw-absolute tw-left-0 tw-top-0 tw-rounded-lg"
@@ -58,9 +58,8 @@
<ng-container *ngTemplateOutlet="newLoginItem"></ng-container>
</div>
<div
class="tw-absolute tw-left-0 tw-top-0 tw-opacity-0 md:tw-opacity-100 md:tw-relative tw-w-[15rem] tw-max-w-full tw-aspect-[0.807]"
>
<!-- Use `tw-relative` on the first video container to maintain the proper spacing -->
<div [ngClass]="videoContainerClass" class="tw-relative">
<div
*ngIf="!allVideosLoaded"
class="tw-animate-pulse tw-bg-secondary-300 tw-size-full tw-absolute tw-left-0 tw-top-0 tw-rounded-lg"
@@ -69,9 +68,7 @@
<ng-container *ngTemplateOutlet="browserExtensionEasyAccess"></ng-container>
</div>
<div
class="tw-absolute tw-left-0 tw-top-0 tw-opacity-0 md:tw-opacity-100 md:tw-relative tw-w-[15rem] tw-max-w-full tw-aspect-[0.807]"
>
<div [ngClass]="videoContainerClass">
<div
*ngIf="!allVideosLoaded"
class="tw-animate-pulse tw-bg-secondary-300 tw-size-full tw-absolute tw-left-0 tw-top-0 tw-rounded-lg"

View File

@@ -21,7 +21,14 @@ describe("AddExtensionVideosComponent", () => {
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);
}));
});
});
});

View File

@@ -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");
}
}

View File

@@ -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<boolean>;
restore?: (c: CipherViewLike) => Promise<boolean>;
}
export const VaultItemDialogResult = {

View File

@@ -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 }}
</button>
<ng-container *ngIf="cipher.hasAttachments">
<ng-container *ngIf="hasAttachments">
<i
class="bwi bwi-paperclip tw-ml-2 tw-leading-normal"
appStopProp
@@ -50,7 +50,7 @@
</ng-container>
</div>
<br />
<span class="tw-text-sm tw-text-muted" appStopProp>{{ cipher.subTitle }}</span>
<span class="tw-text-sm tw-text-muted" appStopProp>{{ subtitle }}</span>
</td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="showOwner" class="tw-hidden lg:tw-table-cell">
<app-org-badge
@@ -76,7 +76,7 @@
</td>
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
<button
*ngIf="cipher.decryptionFailure"
*ngIf="decryptionFailure"
[disabled]="disabled || !canManageCollection"
[bitMenuTriggerFor]="corruptedCipherOptions"
size="small"
@@ -89,12 +89,12 @@
<button bitMenuItem *ngIf="canDeleteCipher" (click)="deleteCipher()" type="button">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }}
{{ (isDeleted ? "permanentlyDelete" : "delete") | i18n }}
</span>
</button>
</bit-menu>
<button
*ngIf="!cipher.decryptionFailure"
*ngIf="!decryptionFailure"
[disabled]="disabled || disableMenu"
[bitMenuTriggerFor]="cipherOptions"
size="small"
@@ -105,11 +105,11 @@
></button>
<bit-menu #cipherOptions>
<ng-container *ngIf="isNotDeletedLoginCipher">
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="cipher.login.username">
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="hasUsernameToCopy">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }}
</button>
<button bitMenuItem type="button" (click)="copy('password')" *ngIf="cipher.login.password">
<button bitMenuItem type="button" (click)="copy('password')" *ngIf="hasPasswordToCopy">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPassword" | i18n }}
</button>
@@ -119,9 +119,9 @@
</button>
<a
bitMenuItem
*ngIf="cipher.login.canLaunch"
*ngIf="canLaunch"
type="button"
[href]="cipher.login.launchUri"
[href]="launchUri"
target="_blank"
rel="noreferrer"
>
@@ -151,19 +151,14 @@
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
<button
bitMenuItem
(click)="restore()"
type="button"
*ngIf="cipher.isDeleted && canRestoreCipher"
>
<button bitMenuItem (click)="restore()" type="button" *ngIf="isDeleted && canRestoreCipher">
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restore" | i18n }}
</button>
<button bitMenuItem *ngIf="canDeleteCipher" (click)="deleteCipher()" type="button">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }}
{{ (isDeleted ? "permanentlyDelete" : "delete") | i18n }}
</span>
</button>
</bit-menu>

View File

@@ -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<C extends CipherViewLike> 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<VaultItemEvent>();
@Output() onEvent = new EventEmitter<VaultItemEvent<C>>();
@Input() checked: boolean;
@Output() checkedToggled = new EventEmitter<void>();
@@ -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)
);
}

View File

@@ -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<C extends CipherViewLike> {
protected RowHeightClass = RowHeightClass;
protected Unassigned = "unassigned";
@@ -36,7 +37,7 @@ export class VaultCollectionRowComponent {
@Input() groups: GroupView[];
@Input() showPermissionsColumn: boolean;
@Output() onEvent = new EventEmitter<VaultItemEvent>();
@Output() onEvent = new EventEmitter<VaultItemEvent<C>>();
@Input() checked: boolean;
@Output() checkedToggled = new EventEmitter<void>();

View File

@@ -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<C extends CipherViewLike> =
| { 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<C>[] }
| { type: "copyField"; item: C; field: "username" | "password" | "totp" }
| { type: "moveToFolder"; items: C[] }
| { type: "assignToCollections"; items: C[] };

View File

@@ -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<C extends CipherViewLike> {
collection?: CollectionView;
cipher?: CipherView;
cipher?: C;
}

View File

@@ -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<C extends CipherViewLike> {
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<VaultItemEvent>();
@Output() onEvent = new EventEmitter<VaultItemEvent<C>>();
protected editableItems: VaultItem[] = [];
protected dataSource = new TableDataSource<VaultItem>();
protected selection = new SelectionModel<VaultItem>(true, [], true);
protected editableItems: VaultItem<C>[] = [];
protected dataSource = new TableDataSource<VaultItem<C>>();
protected selection = new SelectionModel<VaultItem<C>>(true, [], true);
protected canDeleteSelected$: Observable<boolean>;
protected canRestoreSelected$: Observable<boolean>;
protected disableMenu$: Observable<boolean>;
@@ -233,7 +236,7 @@ export class VaultItemsComponent {
: this.selection.select(...this.editableItems.slice(0, MaxSelectionCount));
}
protected event(event: VaultItemEvent) {
protected event(event: VaultItemEvent<C>) {
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<C>) {
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<C>[] = this.collections.map((collection) => ({ collection }));
const ciphers: VaultItem<C>[] = this.ciphers.map((cipher) => ({
cipher,
}));
const items: VaultItem<C>[] = [].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<C>, b: VaultItem<C>, 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<C>, b: VaultItem<C>, 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<C>, b: VaultItem<C>, direction: SortDirection) => {
const getPermissionPriority = (item: VaultItem<C>): 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<C>, b: VaultItem<C>): number {
const getName = (item: VaultItem<C>) => 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<C>,
b: VaultItem<C>,
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;
}

View File

@@ -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<VaultItemsComponent>;
type Story = StoryObj<VaultItemsComponent<CipherViewLike>>;
export const Individual: Story = {
args: {

View File

@@ -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", () => {

View File

@@ -1,32 +0,0 @@
<form [bitSubmit]="submitAndClose" [formGroup]="formGroup">
<bit-dialog>
<span bitDialogTitle>
{{ title }}
</span>
<span bitDialogContent>
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>
<input bitInput id="name" formControlName="name" />
</bit-form-field>
</span>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" bitFormButton type="submit">
<span>{{ "save" | i18n }}</span>
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
<div class="tw-m-0 tw-ml-auto">
<button
buttonType="danger"
bitIconButton="bwi-trash"
bitFormButton
type="button"
appA11yTitle="{{ 'delete' | i18n }}"
*ngIf="editMode"
[bitAction]="deleteAndClose"
></button>
</div>
</ng-container>
</bit-dialog>
</form>

View File

@@ -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<FolderAddEditDialogResult>,
@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<typeof FolderAddEditDialogResult>;
/**
* 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<FolderAddEditDialogParams>,
) {
return dialogService.open<FolderAddEditDialogResult, FolderAddEditDialogParams>(
FolderAddEditComponent,
config,
);
}

View File

@@ -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,

View File

@@ -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<FolderView[]> {
// If no org or "My Vault" is selected, show all folders

View File

@@ -221,7 +221,7 @@ function createCipher(options: Partial<CipherView> = {}) {
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;

View File

@@ -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

View File

@@ -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<CipherType>();

View File

@@ -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<C extends CipherViewLike> 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<CollectionView> | 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<C>(
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<C>) {
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<boolean> => {
if (!c.isDeleted) {
restore = async (c: C): Promise<boolean> => {
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<C>[]) {
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<boolean> {
async deleteCipher(c: C): Promise<boolean> {
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<string | undefined> {
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;
}
}
/**

View File

@@ -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;

View File

@@ -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": {

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,63 @@
<bit-item-group>
<bit-item *ngFor="let device of devices">
@if (device.pendingAuthRequest) {
<button
class="tw-relative"
bit-item-content
type="button"
[attr.tabindex]="device.pendingAuthRequest != null ? 0 : null"
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
(keydown.enter)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
>
<!-- Default Content -->
<span class="tw-text-base">{{ device.displayName }}</span>
<!-- Default Trailing Content -->
<span class="tw-absolute tw-top-[6px] tw-right-3" slot="default-trailing">
<span bitBadge variant="warning">
{{ "requestPending" | i18n }}
</span>
</span>
<!-- Secondary Content -->
<span slot="secondary" class="tw-text-sm">
<span>{{ "needsApproval" | i18n }}</span>
<div>
<span class="tw-font-semibold"> {{ "firstLogin" | i18n }}: </span>
<span>{{ device.firstLogin | date: "medium" }}</span>
</div>
</span>
</button>
} @else {
<bit-item-content ngClass="tw-relative">
<!-- Default Content -->
<span class="tw-text-base">{{ device.displayName }}</span>
<!-- Default Trailing Content -->
<div
*ngIf="device.isCurrentDevice"
class="tw-absolute tw-top-[6px] tw-right-3"
slot="default-trailing"
>
<span bitBadge variant="primary">
{{ "currentSession" | i18n }}
</span>
</div>
<!-- Secondary Content -->
<div slot="secondary" class="tw-text-sm">
@if (device.isTrusted) {
<span>{{ "trusted" | i18n }}</span>
} @else {
<br />
}
<div>
<span class="tw-font-semibold">{{ "firstLogin" | i18n }}: </span>
<span>{{ device.firstLogin | date: "medium" }}</span>
</div>
</div>
</bit-item-content>
}
</bit-item>
</bit-item-group>

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,62 @@
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="50">
<!-- Table Header -->
<ng-container header>
<th
*ngFor="let column of columnConfig"
[class]="column.headerClass"
bitCell
[bitSortable]="column.sortable ? column.name : ''"
[default]="column.name === 'loginStatus' ? 'desc' : false"
scope="col"
role="columnheader"
>
{{ column.title }}
</th>
</ng-container>
<!-- Table Rows -->
<ng-template bitRowDef let-device>
<!-- Column: Device Name -->
<td bitCell class="tw-flex tw-gap-2">
<div class="tw-flex tw-items-center tw-justify-center tw-w-10">
<i [class]="device.icon" class="bwi-lg" aria-hidden="true"></i>
</div>
<div>
@if (device.pendingAuthRequest) {
<a
bitLink
href="#"
appStopClick
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
>
{{ device.displayName }}
</a>
<div class="tw-text-sm tw-text-muted">
{{ "needsApproval" | i18n }}
</div>
} @else {
<span>{{ device.displayName }}</span>
<div *ngIf="device.isTrusted" class="tw-text-sm tw-text-muted">
{{ "trusted" | i18n }}
</div>
}
</div>
</td>
<!-- Column: Login Status -->
<td bitCell>
<div class="tw-flex tw-gap-1">
<span *ngIf="device.isCurrentDevice" bitBadge variant="primary">
{{ "currentSession" | i18n }}
</span>
<span *ngIf="device.pendingAuthRequest" bitBadge variant="warning">
{{ "requestPending" | i18n }}
</span>
</div>
</td>
<!-- Column: First Login -->
<td bitCell>{{ device.firstLogin | date: "medium" }}</td>
</ng-template>
</bit-table-scroll>

View File

@@ -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<DeviceDisplayData>();
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,
);
}
}
}

View File

@@ -0,0 +1,40 @@
<div *ngIf="showHeaderInfo" class="tw-mt-6 tw-mb-2 tw-pb-2.5">
<div class="tw-flex tw-items-center tw-gap-2 tw-mb-5">
<h1 class="tw-m-0">{{ "devices" | i18n }}</h1>
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-flex tw-items-center tw-size-4"
[bitPopoverTriggerFor]="infoPopover"
position="right-start"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</button>
<bit-popover [title]="'whatIsADevice' | i18n" #infoPopover>
<p>{{ "aDeviceIs" | i18n }}</p>
</bit-popover>
</div>
<p>
{{ "deviceListDescriptionTemp" | i18n }}
</p>
</div>
@if (initializing) {
<div class="tw-flex tw-justify-center tw-items-center tw-p-4">
<i class="bwi bwi-spinner bwi-spin tw-text-2xl" aria-hidden="true"></i>
</div>
} @else {
<!-- Table View: displays on medium to large screens -->
<auth-device-management-table
ngClass="tw-hidden md:tw-block"
[devices]="devices"
></auth-device-management-table>
<!-- List View: displays on small screens -->
<auth-device-management-item-group
ngClass="md:tw-hidden"
[devices]="devices"
></auth-device-management-item-group>
}

View File

@@ -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<string, string> = {
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;
}
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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).
<br>
### 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*.

View File

@@ -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<AccountService>();
const authService = mock<AuthService>();
accountService.activeAccount$ = new BehaviorSubject<Account | null>(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");
});
});

View File

@@ -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;
};
}

View File

@@ -1191,7 +1191,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DevicesServiceAbstraction,
useClass: DevicesServiceImplementation,
deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction],
deps: [AppIdServiceAbstraction, DevicesApiServiceAbstraction, I18nServiceAbstraction],
}),
safeProvider({
provide: AuthRequestApiServiceAbstraction,

View File

@@ -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<CipherView>();
cipher = input.required<CipherViewLike>();
imageLoaded = signal(false);

View File

@@ -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<C extends CipherViewLike> implements OnInit, OnDestroy {
@Input() activeCipherId: string = null;
@Output() onCipherClicked = new EventEmitter<CipherView>();
@Output() onCipherRightClicked = new EventEmitter<CipherView>();
@Output() onCipherClicked = new EventEmitter<C>();
@Output() onCipherRightClicked = new EventEmitter<C>();
@Output() onAddCipher = new EventEmitter<CipherType | undefined>();
@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<void>();
private isSearchable: boolean = false;
private _searchText$ = new BehaviorSubject<string>("");
@@ -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(

View File

@@ -25,7 +25,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
this.cipherService.cipherListViews$(userId),
this.organizationService.organizations$(userId),
this.collectionService.decryptedCollections$,
]).pipe(

View File

@@ -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;
};

View File

@@ -1,11 +1,22 @@
# Authentication Flows Documentation
# Login via Auth Request Documentation
<br>
**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)
<br>
## 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
<br>
## 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.
<br>
## 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"<br>[`/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.
<br>
## 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.
<br>
### 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
<br>
### 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
<br>
### State Cleanup
State cleanup occurs in several scenarios:

View File

@@ -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<DeviceView>;
abstract deactivateDevice$(deviceId: string): Observable<void>;
abstract getCurrentDevice$(): Observable<DeviceResponse>;
abstract getReadableDeviceTypeName(deviceType: DeviceType): string;
}

View File

@@ -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,
}

View File

@@ -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;
}
}

View File

@@ -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, DeviceTypeMetadata> = {
[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" },

View File

@@ -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 */

View File

@@ -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<CipherWithIdRequest> {
abstract cipherViews$(userId: UserId): Observable<CipherView[]>;
abstract cipherListViews$(userId: UserId): Observable<CipherListView[] | CipherView[]>;
abstract ciphers$(userId: UserId): Observable<Record<CipherId, CipherData>>;
abstract localData$(userId: UserId): Observable<Record<CipherId, LocalData>>;
/**
@@ -65,12 +68,12 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting,
): Promise<CipherView[]>;
abstract filterCiphersForUrl(
ciphers: CipherView[],
abstract filterCiphersForUrl<C extends CipherViewLike = CipherView>(
ciphers: C[],
url: string,
includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting,
): Promise<CipherView[]>;
): Promise<C[]>;
abstract getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]>;
/**
* 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<Ciphe
userId: UserId,
admin: boolean,
): Promise<CipherData>;
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<any>;
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise<any>;
@@ -251,4 +254,10 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
response: Response,
userId: UserId,
): Promise<Uint8Array | null>;
/**
* Decrypts the full `CipherView` for a given `CipherViewLike`.
* When a `CipherView` instance is passed, it returns it as is.
*/
abstract getFullCipherView(c: CipherViewLike): Promise<CipherView>;
}

View File

@@ -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<IndexedEntityId | null>;
@@ -16,12 +17,16 @@ export abstract class SearchService {
ciphersToIndex: CipherView[],
indexedEntityGuid?: string,
) => Promise<void>;
searchCiphers: (
searchCiphers: <C extends CipherViewLike>(
userId: UserId,
query: string,
filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[],
ciphers?: CipherView[],
) => Promise<CipherView[]>;
searchCiphersBasic: (ciphers: CipherView[], query: string, deleted?: boolean) => CipherView[];
filter?: ((cipher: C) => boolean) | ((cipher: C) => boolean)[],
ciphers?: C[],
) => Promise<C[]>;
searchCiphersBasic: <C extends CipherViewLike>(
ciphers: C[],
query: string,
deleted?: boolean,
) => C[];
searchSends: (sends: SendView[], query: string) => SendView[];
}

View File

@@ -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:

View File

@@ -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;
}),
);
}

View File

@@ -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<string, Cipher[]>,
);
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<CipherView>(
ciphers,
url,
includeOtherTypes,
defaultMatch,
),
),
),
);
}
async filterCiphersForUrl(
ciphers: CipherView[],
async filterCiphersForUrl<C extends CipherViewLike>(
ciphers: C[],
url: string,
includeOtherTypes?: CipherType[],
defaultMatch: UriMatchStrategySetting = null,
): Promise<CipherView[]> {
): Promise<C[]> {
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<CipherView> {
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);
}
}

View File

@@ -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);
}),
),

View File

@@ -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) {

View File

@@ -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<boolean> {
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<C extends CipherViewLike>(
userId: UserId,
query: string,
filter: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[] = null,
ciphers: CipherView[],
): Promise<CipherView[]> {
const results: CipherView[] = [];
filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null,
ciphers: C[],
): Promise<C[]> {
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<string, CipherView>();
const ciphersMap = new Map<string, C>();
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<C extends CipherViewLike>(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;
}

View File

@@ -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;

View File

@@ -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<string>();
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);
});
});
});

View File

@@ -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<string>,
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<string, CopyableCipherFields> = {
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;
};

View File

@@ -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";
}
}

View File

@@ -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();
});
});

View File

@@ -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<ButtonType, string[]> = {
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<boolean>(false);
private el = inject(ElementRef<HTMLButtonElement>);
constructor() {
ariaDisableElement(this.el.nativeElement, this.disabledAttr as Signal<boolean | undefined>);
}
disabled = model<boolean>(false);
}

Some files were not shown because too many files have changed in this diff Show More