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:
73
.github/workflows/scan.yml
vendored
73
.github/workflows/scan.yml
vendored
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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$);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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" },
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -610,7 +610,7 @@ const routes: Routes = [
|
||||
data: {
|
||||
hideCardWrapper: true,
|
||||
hideIcon: true,
|
||||
maxWidth: "3xl",
|
||||
maxWidth: "4xl",
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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*.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -1191,7 +1191,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: DevicesServiceAbstraction,
|
||||
useClass: DevicesServiceImplementation,
|
||||
deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction],
|
||||
deps: [AppIdServiceAbstraction, DevicesApiServiceAbstraction, I18nServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AuthRequestApiServiceAbstraction,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
9
libs/common/src/vault/types/cipher-like.ts
Normal file
9
libs/common/src/vault/types/cipher-like.ts
Normal 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;
|
||||
624
libs/common/src/vault/utils/cipher-view-like-utils.spec.ts
Normal file
624
libs/common/src/vault/utils/cipher-view-like-utils.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
301
libs/common/src/vault/utils/cipher-view-like-utils.ts
Normal file
301
libs/common/src/vault/utils/cipher-view-like-utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user