mirror of
https://github.com/bitwarden/browser
synced 2026-02-07 04:03:29 +00:00
Merge branch 'main' of github.com:bitwarden/clients into feature/PM-30737-Migrate-DeleteAccount
This commit is contained in:
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -4,6 +4,10 @@
|
||||
#
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
## Global styles are owned by UIF
|
||||
*.scss @bitwarden/team-ui-foundation
|
||||
*.css @bitwarden/team-ui-foundation
|
||||
|
||||
## Desktop native module ##
|
||||
apps/desktop/desktop_native @bitwarden/team-platform-dev
|
||||
apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev
|
||||
|
||||
@@ -6127,6 +6127,9 @@
|
||||
"emailPlaceholder": {
|
||||
"message": "user@bitwarden.com , user@acme.com"
|
||||
},
|
||||
"emailProtected": {
|
||||
"message": "Email protected"
|
||||
},
|
||||
"sendPasswordHelperText": {
|
||||
"message": "Individuals will need to enter the password to view this Send",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
|
||||
@@ -46,7 +46,9 @@ export class RestoreCommand {
|
||||
return Response.notFound();
|
||||
}
|
||||
|
||||
if (cipher.archivedDate && isArchivedVaultEnabled) {
|
||||
// Determine if restoring from archive or trash
|
||||
// When a cipher is archived and deleted, restore from the trash first
|
||||
if (cipher.archivedDate && cipher.deletedDate == null && isArchivedVaultEnabled) {
|
||||
return this.restoreArchivedCipher(cipher, activeUserId);
|
||||
} else {
|
||||
return this.restoreDeletedCipher(cipher, activeUserId);
|
||||
|
||||
@@ -73,11 +73,14 @@
|
||||
class="flex-list-item"
|
||||
>
|
||||
<span class="item-icon" aria-hidden="true">
|
||||
<i class="bwi bwi-fw bwi-lg" [ngClass]="s.type == 0 ? 'bwi-file-text' : 'bwi-file'"></i>
|
||||
<i
|
||||
class="bwi bwi-fw bwi-lg tw-text-muted"
|
||||
[ngClass]="s.type == 0 ? 'bwi-file-text' : 'bwi-file'"
|
||||
></i>
|
||||
</span>
|
||||
<span class="item-content">
|
||||
<span class="item-title">
|
||||
{{ s.name }}
|
||||
<span class="title-text">{{ s.name }}</span>
|
||||
<span class="title-badges">
|
||||
<ng-container *ngIf="s.disabled">
|
||||
<i
|
||||
@@ -88,15 +91,17 @@
|
||||
></i>
|
||||
<span class="sr-only">{{ "disabled" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.password">
|
||||
@if (s.authType !== authType.None) {
|
||||
@let titleKey =
|
||||
s.authType === authType.Email ? "emailProtected" : "passwordProtected";
|
||||
<i
|
||||
class="bwi bwi-key"
|
||||
class="bwi bwi-lock"
|
||||
appStopProp
|
||||
title="{{ 'password' | i18n }}"
|
||||
title="{{ titleKey | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "password" | i18n }}</span>
|
||||
</ng-container>
|
||||
<span class="tw-sr-only">{{ titleKey | i18n }}</span>
|
||||
}
|
||||
<ng-container *ngIf="s.maxAccessCountReached">
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
@@ -53,6 +54,8 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro
|
||||
sendId: string;
|
||||
action: Action = Action.None;
|
||||
|
||||
authType = AuthType;
|
||||
|
||||
constructor(
|
||||
sendService: SendService,
|
||||
i18nService: I18nService,
|
||||
|
||||
@@ -4590,5 +4590,32 @@
|
||||
"sendPasswordHelperText": {
|
||||
"message": "Individuals will need to enter the password to view this Send",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
},
|
||||
"emailProtected": {
|
||||
"message": "Email protected"
|
||||
},
|
||||
"emails": {
|
||||
"message": "Emails"
|
||||
},
|
||||
"noAuth": {
|
||||
"message": "Anyone with the link"
|
||||
},
|
||||
"anyOneWithPassword": {
|
||||
"message": "Anyone with a password set by you"
|
||||
},
|
||||
"whoCanView": {
|
||||
"message": "Who can view"
|
||||
},
|
||||
"specificPeople": {
|
||||
"message": "Specific people"
|
||||
},
|
||||
"emailVerificationDesc": {
|
||||
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
|
||||
},
|
||||
"enterMultipleEmailsSeparatedByComma": {
|
||||
"message": "Enter multiple emails by separating with a comma."
|
||||
},
|
||||
"emailPlaceholder": {
|
||||
"message": "user@bitwarden.com , user@acme.com"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,11 +122,17 @@
|
||||
display: block;
|
||||
overflow-x: hidden;
|
||||
.item-title {
|
||||
display: block;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
.title-text {
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.title-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
@include themify($themes) {
|
||||
color: themed("mutedColor");
|
||||
}
|
||||
|
||||
@@ -920,14 +920,9 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
cipher?: CipherView,
|
||||
activeCollectionId?: CollectionId,
|
||||
) {
|
||||
const organization = await firstValueFrom(this.organization$);
|
||||
const disableForm = cipher ? !cipher.edit && !organization.canEditAllCiphers : false;
|
||||
// If the form is disabled, force the mode into `view`
|
||||
const dialogMode = disableForm ? "view" : mode;
|
||||
this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, {
|
||||
mode: dialogMode,
|
||||
mode,
|
||||
formConfig,
|
||||
disableForm,
|
||||
activeCollectionId,
|
||||
isAdminConsoleAction: true,
|
||||
restore: this.restore,
|
||||
|
||||
@@ -186,13 +186,10 @@ export abstract class CipherReportComponent implements OnDestroy {
|
||||
cipher: CipherView,
|
||||
activeCollectionId?: CollectionId,
|
||||
) {
|
||||
const disableForm = cipher ? !cipher.edit && !this.organization?.canEditAllCiphers : false;
|
||||
|
||||
this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, {
|
||||
mode,
|
||||
formConfig,
|
||||
activeCollectionId,
|
||||
disableForm,
|
||||
isAdminConsoleAction: this.organization != null,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, takeUntil, tap } from "rxjs";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
@@ -51,7 +47,7 @@ export class ExposedPasswordsReportComponent
|
||||
extends BaseExposedPasswordsReportComponent
|
||||
implements OnInit
|
||||
{
|
||||
manageableCiphers: Cipher[];
|
||||
private manageableCiphers: Cipher[] = [];
|
||||
|
||||
constructor(
|
||||
cipherService: CipherService,
|
||||
@@ -82,20 +78,25 @@ export class ExposedPasswordsReportComponent
|
||||
|
||||
async ngOnInit() {
|
||||
this.isAdminConsoleActive = true;
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent.parent.params.subscribe(async (params) => {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.organization = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(params.organizationId)),
|
||||
);
|
||||
this.manageableCiphers = await this.cipherService.getAll(userId);
|
||||
});
|
||||
this.route.parent?.parent?.params
|
||||
.pipe(
|
||||
tap(async (params) => {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.organization = await firstValueFrom(
|
||||
this.organizationService.organizations$(userId).pipe(getById(params.organizationId)),
|
||||
);
|
||||
this.manageableCiphers = await this.cipherService.getAll(userId);
|
||||
}),
|
||||
takeUntil(this.destroyed$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
getAllCiphers(): Promise<CipherView[]> {
|
||||
return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
|
||||
async getAllCiphers(): Promise<CipherView[]> {
|
||||
if (this.organization) {
|
||||
return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
canManageCipher(c: CipherView): boolean {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ChangeDetectorRef, Component, OnInit, ChangeDetectionStrategy } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { firstValueFrom, map, takeUntil } from "rxjs";
|
||||
import { firstValueFrom, takeUntil, tap } from "rxjs";
|
||||
|
||||
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
@@ -81,27 +82,24 @@ export class InactiveTwoFactorReportComponent
|
||||
this.isAdminConsoleActive = true;
|
||||
|
||||
this.route.parent?.parent?.params
|
||||
?.pipe(takeUntil(this.destroyed$))
|
||||
// eslint-disable-next-line rxjs/no-async-subscribe
|
||||
.subscribe(async (params) => {
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
if (userId) {
|
||||
.pipe(
|
||||
tap(async (params) => {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.organization = await firstValueFrom(
|
||||
this.organizationService.organizations$(userId).pipe(getById(params.organizationId)),
|
||||
);
|
||||
this.manageableCiphers = await this.cipherService.getAll(userId);
|
||||
await super.ngOnInit();
|
||||
}
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}),
|
||||
takeUntil(this.destroyed$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async getAllCiphers(): Promise<CipherView[]> {
|
||||
if (this.organization) {
|
||||
return await this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
|
||||
return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, takeUntil, tap } from "rxjs";
|
||||
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
@@ -50,7 +46,7 @@ export class ReusedPasswordsReportComponent
|
||||
extends BaseReusedPasswordsReportComponent
|
||||
implements OnInit
|
||||
{
|
||||
manageableCiphers: Cipher[];
|
||||
manageableCiphers: Cipher[] = [];
|
||||
|
||||
constructor(
|
||||
cipherService: CipherService,
|
||||
@@ -79,21 +75,27 @@ export class ReusedPasswordsReportComponent
|
||||
|
||||
async ngOnInit() {
|
||||
this.isAdminConsoleActive = true;
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent.parent.params.subscribe(async (params) => {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.organization = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(params.organizationId)),
|
||||
);
|
||||
this.manageableCiphers = await this.cipherService.getAll(userId);
|
||||
await super.ngOnInit();
|
||||
});
|
||||
|
||||
this.route.parent?.parent?.params
|
||||
.pipe(
|
||||
tap(async (params) => {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.organization = await firstValueFrom(
|
||||
this.organizationService.organizations$(userId).pipe(getById(params.organizationId)),
|
||||
);
|
||||
this.manageableCiphers = await this.cipherService.getAll(userId);
|
||||
await super.ngOnInit();
|
||||
}),
|
||||
takeUntil(this.destroyed$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
getAllCiphers(): Promise<CipherView[]> {
|
||||
return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
|
||||
async getAllCiphers(): Promise<CipherView[]> {
|
||||
if (this.organization) {
|
||||
return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
canManageCipher(c: CipherView): boolean {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
import { firstValueFrom, takeUntil, tap } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
@@ -51,7 +48,7 @@ export class UnsecuredWebsitesReportComponent
|
||||
implements OnInit
|
||||
{
|
||||
// Contains a list of ciphers, the user running the report, can manage
|
||||
private manageableCiphers: Cipher[];
|
||||
private manageableCiphers: Cipher[] = [];
|
||||
|
||||
constructor(
|
||||
cipherService: CipherService,
|
||||
@@ -82,23 +79,26 @@ export class UnsecuredWebsitesReportComponent
|
||||
|
||||
async ngOnInit() {
|
||||
this.isAdminConsoleActive = true;
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent.parent.params.subscribe(async (params) => {
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
this.organization = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(params.organizationId)),
|
||||
);
|
||||
this.manageableCiphers = await this.cipherService.getAll(userId);
|
||||
await super.ngOnInit();
|
||||
});
|
||||
this.route.parent?.parent?.params
|
||||
.pipe(
|
||||
tap(async (params) => {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.organization = await firstValueFrom(
|
||||
this.organizationService.organizations$(userId).pipe(getById(params.organizationId)),
|
||||
);
|
||||
this.manageableCiphers = await this.cipherService.getAll(userId);
|
||||
await super.ngOnInit();
|
||||
}),
|
||||
takeUntil(this.destroyed$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
getAllCiphers(): Promise<CipherView[]> {
|
||||
return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
|
||||
async getAllCiphers(): Promise<CipherView[]> {
|
||||
if (this.organization) {
|
||||
return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
protected canManageCipher(c: CipherView): boolean {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, takeUntil, tap } from "rxjs";
|
||||
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
@@ -51,7 +47,7 @@ export class WeakPasswordsReportComponent
|
||||
extends BaseWeakPasswordsReportComponent
|
||||
implements OnInit
|
||||
{
|
||||
manageableCiphers: Cipher[];
|
||||
private manageableCiphers: Cipher[] = [];
|
||||
|
||||
constructor(
|
||||
cipherService: CipherService,
|
||||
@@ -82,22 +78,26 @@ export class WeakPasswordsReportComponent
|
||||
|
||||
async ngOnInit() {
|
||||
this.isAdminConsoleActive = true;
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent.parent.params.subscribe(async (params) => {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
this.organization = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(params.organizationId)),
|
||||
);
|
||||
this.manageableCiphers = await this.cipherService.getAll(userId);
|
||||
await super.ngOnInit();
|
||||
});
|
||||
this.route.parent?.parent?.params
|
||||
.pipe(
|
||||
tap(async (params) => {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.organization = await firstValueFrom(
|
||||
this.organizationService.organizations$(userId).pipe(getById(params.organizationId)),
|
||||
);
|
||||
this.manageableCiphers = await this.cipherService.getAll(userId);
|
||||
await super.ngOnInit();
|
||||
}),
|
||||
takeUntil(this.destroyed$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
getAllCiphers(): Promise<CipherView[]> {
|
||||
return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
|
||||
async getAllCiphers(): Promise<CipherView[]> {
|
||||
if (this.organization) {
|
||||
return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
canManageCipher(c: CipherView): boolean {
|
||||
|
||||
@@ -87,11 +87,6 @@ export interface VaultItemDialogParams {
|
||||
*/
|
||||
formConfig: CipherFormConfig;
|
||||
|
||||
/**
|
||||
* If true, the "edit" button will be disabled in the dialog.
|
||||
*/
|
||||
disableForm?: boolean;
|
||||
|
||||
/**
|
||||
* The ID of the active collection. This is know the collection filter selected by the user.
|
||||
*/
|
||||
@@ -273,7 +268,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
protected get disableEdit() {
|
||||
return this.params.disableForm;
|
||||
return !this.canEdit;
|
||||
}
|
||||
|
||||
protected get showEdit() {
|
||||
@@ -314,6 +309,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
protected canDelete = false;
|
||||
|
||||
protected canEdit = false;
|
||||
|
||||
protected attachmentsButtonDisabled = false;
|
||||
|
||||
protected confirmedPremiumUpgrade = false;
|
||||
@@ -372,6 +369,20 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
),
|
||||
);
|
||||
|
||||
this.canEdit = await firstValueFrom(
|
||||
this.cipherAuthorizationService.canEditCipher$(
|
||||
this.cipher,
|
||||
this.params.isAdminConsoleAction,
|
||||
),
|
||||
);
|
||||
|
||||
// If user cannot edit and dialog opened in form mode, force to view mode
|
||||
if (!this.canEdit && this.params.mode === "form") {
|
||||
this.params.mode = "view";
|
||||
this.loadForm = false;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
await this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientViewed,
|
||||
this.cipher.id,
|
||||
|
||||
@@ -1247,6 +1247,9 @@
|
||||
"selectAll": {
|
||||
"message": "Select all"
|
||||
},
|
||||
"deselectAll": {
|
||||
"message": "Deselect all"
|
||||
},
|
||||
"unselectAll": {
|
||||
"message": "Unselect all"
|
||||
},
|
||||
@@ -10496,6 +10499,9 @@
|
||||
"failedToSaveIntegration": {
|
||||
"message": "Failed to save integration. Please try again later."
|
||||
},
|
||||
"mustBeOrganizationOwnerAdmin": {
|
||||
"message": "You must be an Organization Owner or Admin to perform this action."
|
||||
},
|
||||
"mustBeOrgOwnerToPerformAction": {
|
||||
"message": "You must be the organization owner to perform this action."
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
AllActivitiesService,
|
||||
RiskInsightsDataService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { SecurityTask, SecurityTaskStatus } from "@bitwarden/common/vault/tasks";
|
||||
@@ -170,7 +171,16 @@ export class PasswordChangeMetricComponent implements OnInit {
|
||||
variant: "success",
|
||||
title: this.i18nService.t("success"),
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse && error.statusCode === 404) {
|
||||
this.toastService.showToast({
|
||||
message: this.i18nService.t("mustBeOrganizationOwnerAdmin"),
|
||||
variant: "error",
|
||||
title: this.i18nService.t("error"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
message: this.i18nService.t("unexpectedError"),
|
||||
variant: "error",
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { from, switchMap, take } from "rxjs";
|
||||
import { catchError, EMPTY, from, switchMap, take } from "rxjs";
|
||||
|
||||
import {
|
||||
ApplicationHealthReportDetail,
|
||||
RiskInsightsDataService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
@@ -289,18 +290,18 @@ export class NewApplicationsDialogComponent {
|
||||
),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: this.i18nService.t("applicationReviewSaved"),
|
||||
message: this.i18nService.t("newApplicationsReviewed"),
|
||||
});
|
||||
this.saving.set(false);
|
||||
this.handleAssigningCompleted();
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof ErrorResponse && error.statusCode === 404) {
|
||||
this.toastService.showToast({
|
||||
message: this.i18nService.t("mustBeOrganizationOwnerAdmin"),
|
||||
variant: "error",
|
||||
title: this.i18nService.t("error"),
|
||||
});
|
||||
|
||||
this.saving.set(false);
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
this.logService.error(
|
||||
"[NewApplicationsDialog] Failed to save application review or assign tasks",
|
||||
error,
|
||||
@@ -311,7 +312,19 @@ export class NewApplicationsDialogComponent {
|
||||
title: this.i18nService.t("errorSavingReviewStatus"),
|
||||
message: this.i18nService.t("pleaseTryAgain"),
|
||||
});
|
||||
},
|
||||
|
||||
this.saving.set(false);
|
||||
return EMPTY;
|
||||
}),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: this.i18nService.t("applicationReviewSaved"),
|
||||
message: this.i18nService.t("newApplicationsReviewed"),
|
||||
});
|
||||
this.saving.set(false);
|
||||
this.handleAssigningCompleted();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -33,11 +33,19 @@
|
||||
<i class="bwi tw-mr-2" [ngClass]="selectedUrls().size ? 'bwi-star-f' : 'bwi-star'"></i>
|
||||
{{ "markAppAsCritical" | i18n }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-download"
|
||||
buttonType="main"
|
||||
[label]="'downloadCSV' | i18n"
|
||||
[disabled]="!dataSource.filteredData.length"
|
||||
(click)="downloadApplicationsCSV()"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<app-table-row-scrollable-m11
|
||||
[dataSource]="dataSource"
|
||||
[showRowMenuForCriticalApps]="false"
|
||||
[selectedUrls]="selectedUrls()"
|
||||
[openApplication]="drawerDetails.invokerId || ''"
|
||||
[checkboxChange]="onCheckboxChange"
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
DrawerDetails,
|
||||
DrawerType,
|
||||
MemberDetails,
|
||||
ReportStatus,
|
||||
RiskInsightsDataService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { RiskInsightsEnrichedData } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-data-service.types";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { TableDataSource, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { ApplicationTableDataSource } from "../shared/app-table-row-scrollable.component";
|
||||
|
||||
import { ApplicationsComponent } from "./applications.component";
|
||||
|
||||
// Helper type to access protected members in tests
|
||||
type ComponentWithProtectedMembers = ApplicationsComponent & {
|
||||
dataSource: TableDataSource<ApplicationTableDataSource>;
|
||||
};
|
||||
|
||||
describe("ApplicationsComponent", () => {
|
||||
let component: ApplicationsComponent;
|
||||
let fixture: ComponentFixture<ApplicationsComponent>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockFileDownloadService: MockProxy<FileDownloadService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockDataService: MockProxy<RiskInsightsDataService>;
|
||||
|
||||
const reportStatus$ = new BehaviorSubject<ReportStatus>(ReportStatus.Complete);
|
||||
const enrichedReportData$ = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
|
||||
const criticalReportResults$ = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
|
||||
const drawerDetails$ = new BehaviorSubject<DrawerDetails>({
|
||||
open: false,
|
||||
invokerId: "",
|
||||
activeDrawerType: DrawerType.None,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockFileDownloadService = mock<FileDownloadService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockDataService = mock<RiskInsightsDataService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
Object.defineProperty(mockDataService, "reportStatus$", { get: () => reportStatus$ });
|
||||
Object.defineProperty(mockDataService, "enrichedReportData$", {
|
||||
get: () => enrichedReportData$,
|
||||
});
|
||||
Object.defineProperty(mockDataService, "criticalReportResults$", {
|
||||
get: () => criticalReportResults$,
|
||||
});
|
||||
Object.defineProperty(mockDataService, "drawerDetails$", { get: () => drawerDetails$ });
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ApplicationsComponent, ReactiveFormsModule],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: FileDownloadService, useValue: mockFileDownloadService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: RiskInsightsDataService, useValue: mockDataService },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: (): string | null => null } } },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ApplicationsComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("downloadApplicationsCSV", () => {
|
||||
const mockApplicationData: ApplicationTableDataSource[] = [
|
||||
{
|
||||
applicationName: "GitHub",
|
||||
passwordCount: 10,
|
||||
atRiskPasswordCount: 3,
|
||||
memberCount: 5,
|
||||
atRiskMemberCount: 2,
|
||||
isMarkedAsCritical: true,
|
||||
atRiskCipherIds: ["cipher1" as CipherId],
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher1" as CipherId],
|
||||
iconCipher: undefined,
|
||||
},
|
||||
{
|
||||
applicationName: "Slack",
|
||||
passwordCount: 8,
|
||||
atRiskPasswordCount: 1,
|
||||
memberCount: 4,
|
||||
atRiskMemberCount: 1,
|
||||
isMarkedAsCritical: false,
|
||||
atRiskCipherIds: ["cipher2" as CipherId],
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher2" as CipherId],
|
||||
iconCipher: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should download CSV with correct data when filteredData has items", () => {
|
||||
// Set up the data source with mock data
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = mockApplicationData;
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
expect(mockFileDownloadService.download).toHaveBeenCalledTimes(1);
|
||||
expect(mockFileDownloadService.download).toHaveBeenCalledWith({
|
||||
fileName: expect.stringContaining("applications"),
|
||||
blobData: expect.any(String),
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should not download when filteredData is empty", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = [];
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
expect(mockFileDownloadService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use translated column headers in CSV", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = mockApplicationData;
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("application");
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("atRiskPasswords");
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("totalPasswords");
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("atRiskMembers");
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("totalMembers");
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("criticalBadge");
|
||||
});
|
||||
|
||||
it("should translate isMarkedAsCritical to 'yes' when true", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = [mockApplicationData[0]]; // Critical app
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("yes");
|
||||
});
|
||||
|
||||
it("should translate isMarkedAsCritical to 'no' when false", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = [mockApplicationData[1]]; // Non-critical app
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("no");
|
||||
});
|
||||
|
||||
it("should include correct application data in CSV export", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = [mockApplicationData[0]];
|
||||
|
||||
let capturedBlobData: string = "";
|
||||
mockFileDownloadService.download.mockImplementation((options) => {
|
||||
capturedBlobData = options.blobData as string;
|
||||
});
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
// Verify the CSV contains the application data
|
||||
expect(capturedBlobData).toContain("GitHub");
|
||||
expect(capturedBlobData).toContain("10"); // passwordCount
|
||||
expect(capturedBlobData).toContain("3"); // atRiskPasswordCount
|
||||
expect(capturedBlobData).toContain("5"); // memberCount
|
||||
expect(capturedBlobData).toContain("2"); // atRiskMemberCount
|
||||
});
|
||||
|
||||
it("should log error when download fails", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = mockApplicationData;
|
||||
|
||||
const testError = new Error("Download failed");
|
||||
mockFileDownloadService.download.mockImplementation(() => {
|
||||
throw testError;
|
||||
});
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
expect(mockLogService.error).toHaveBeenCalledWith(
|
||||
"Failed to download applications CSV",
|
||||
testError,
|
||||
);
|
||||
});
|
||||
|
||||
it("should only export filtered data when filter is applied", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = mockApplicationData;
|
||||
// Apply a filter that only matches "GitHub"
|
||||
(component as ComponentWithProtectedMembers).dataSource.filter = (
|
||||
app: (typeof mockApplicationData)[0],
|
||||
) => app.applicationName === "GitHub";
|
||||
|
||||
let capturedBlobData: string = "";
|
||||
mockFileDownloadService.download.mockImplementation((options) => {
|
||||
capturedBlobData = options.blobData as string;
|
||||
});
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
// Verify only GitHub is in the export (not Slack)
|
||||
expect(capturedBlobData).toContain("GitHub");
|
||||
expect(capturedBlobData).not.toContain("Slack");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
OrganizationReportSummary,
|
||||
ReportStatus,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
@@ -31,6 +33,8 @@ import {
|
||||
TypographyModule,
|
||||
ChipSelectComponent,
|
||||
} from "@bitwarden/components";
|
||||
import { ExportHelper } from "@bitwarden/vault-export-core";
|
||||
import { exportToCSV } from "@bitwarden/web-vault/app/dirt/reports/report-utils";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
@@ -70,6 +74,8 @@ export type ApplicationFilterOption =
|
||||
})
|
||||
export class ApplicationsComponent implements OnInit {
|
||||
destroyRef = inject(DestroyRef);
|
||||
private fileDownloadService = inject(FileDownloadService);
|
||||
private logService = inject(LogService);
|
||||
|
||||
protected ReportStatusEnum = ReportStatus;
|
||||
protected noItemsIcon = Security;
|
||||
@@ -166,6 +172,15 @@ export class ApplicationsComponent implements OnInit {
|
||||
filterFunction(app) &&
|
||||
app.applicationName.toLowerCase().includes(searchText.toLowerCase());
|
||||
|
||||
// filter selectedUrls down to only applications showing with active filters
|
||||
const filteredUrls = new Set<string>();
|
||||
this.dataSource.filteredData?.forEach((row) => {
|
||||
if (this.selectedUrls().has(row.applicationName)) {
|
||||
filteredUrls.add(row.applicationName);
|
||||
}
|
||||
});
|
||||
this.selectedUrls.set(filteredUrls);
|
||||
|
||||
if (this.dataSource?.filteredData?.length === 0) {
|
||||
this.emptyTableExplanation.set(this.i18nService.t("noApplicationsMatchTheseFilters"));
|
||||
} else {
|
||||
@@ -225,4 +240,39 @@ export class ApplicationsComponent implements OnInit {
|
||||
return nextSelected;
|
||||
});
|
||||
};
|
||||
|
||||
downloadApplicationsCSV = () => {
|
||||
try {
|
||||
const data = this.dataSource.filteredData;
|
||||
if (!data || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exportData = data.map((app) => ({
|
||||
applicationName: app.applicationName,
|
||||
atRiskPasswordCount: app.atRiskPasswordCount,
|
||||
passwordCount: app.passwordCount,
|
||||
atRiskMemberCount: app.atRiskMemberCount,
|
||||
memberCount: app.memberCount,
|
||||
isMarkedAsCritical: app.isMarkedAsCritical
|
||||
? this.i18nService.t("yes")
|
||||
: this.i18nService.t("no"),
|
||||
}));
|
||||
|
||||
this.fileDownloadService.download({
|
||||
fileName: ExportHelper.getFileName("applications"),
|
||||
blobData: exportToCSV(exportData, {
|
||||
applicationName: this.i18nService.t("application"),
|
||||
atRiskPasswordCount: this.i18nService.t("atRiskPasswords"),
|
||||
passwordCount: this.i18nService.t("totalPasswords"),
|
||||
atRiskMemberCount: this.i18nService.t("atRiskMembers"),
|
||||
memberCount: this.i18nService.t("totalMembers"),
|
||||
isMarkedAsCritical: this.i18nService.t("criticalBadge"),
|
||||
}),
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to download applications CSV", error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, DestroyRef, inject, OnInit, ChangeDetectionStrategy } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormControl } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { debounceTime, EMPTY, from, map, switchMap, take } from "rxjs";
|
||||
import { catchError, debounceTime, EMPTY, from, map, switchMap, take } from "rxjs";
|
||||
|
||||
import { Security } from "@bitwarden/assets/svg";
|
||||
import {
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
|
||||
import { OrganizationReportSummary } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
@@ -53,7 +52,7 @@ import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks
|
||||
export class CriticalApplicationsComponent implements OnInit {
|
||||
private destroyRef = inject(DestroyRef);
|
||||
protected enableRequestPasswordChange = false;
|
||||
protected organizationId: OrganizationId;
|
||||
protected organizationId: OrganizationId = "" as OrganizationId;
|
||||
noItemsIcon = Security;
|
||||
|
||||
protected dataSource = new TableDataSource<ApplicationTableDataSource>();
|
||||
@@ -151,35 +150,43 @@ export class CriticalApplicationsComponent implements OnInit {
|
||||
});
|
||||
};
|
||||
|
||||
async requestPasswordChange() {
|
||||
requestPasswordChange(): void {
|
||||
this.dataService.criticalApplicationAtRiskCipherIds$
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule
|
||||
take(1), // Handle unsubscribe for one off operation
|
||||
switchMap((cipherIds) => {
|
||||
return from(
|
||||
switchMap((cipherIds) =>
|
||||
from(
|
||||
this.securityTasksService.requestPasswordChangeForCriticalApplications(
|
||||
this.organizationId,
|
||||
cipherIds,
|
||||
),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showToast({
|
||||
message: this.i18nService.t("notifiedMembers"),
|
||||
variant: "success",
|
||||
title: this.i18nService.t("success"),
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
),
|
||||
),
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof ErrorResponse && error.statusCode === 404) {
|
||||
this.toastService.showToast({
|
||||
message: this.i18nService.t("mustBeOrganizationOwnerAdmin"),
|
||||
variant: "error",
|
||||
title: this.i18nService.t("error"),
|
||||
});
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
message: this.i18nService.t("unexpectedError"),
|
||||
variant: "error",
|
||||
title: this.i18nService.t("error"),
|
||||
});
|
||||
},
|
||||
return EMPTY;
|
||||
}),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.toastService.showToast({
|
||||
message: this.i18nService.t("notifiedMembers"),
|
||||
variant: "success",
|
||||
title: this.i18nService.t("success"),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<ng-container>
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53">
|
||||
<bit-table-scroll [dataSource]="dataSource()" [rowSize]="53">
|
||||
<ng-container header>
|
||||
<th></th>
|
||||
<th bitCell>
|
||||
<input
|
||||
data-testid="selectAll"
|
||||
bitCheckbox
|
||||
type="checkbox"
|
||||
[checked]="allAppsSelected()"
|
||||
[bitTooltip]="allAppsSelected() ? ('deselectAll' | i18n) : ('selectAll' | i18n)"
|
||||
(change)="selectAllChanged($event.target)"
|
||||
[attr.aria-label]="allAppsSelected() ? ('deselectAll' | i18n) : ('selectAll' | i18n)"
|
||||
/>
|
||||
</th>
|
||||
<th bitCell></th>
|
||||
<th bitSortable="applicationName" bitCell tabindex="0">{{ "application" | i18n }}</th>
|
||||
<th bitSortable="atRiskPasswordCount" bitCell default="desc" tabindex="0">
|
||||
@@ -20,17 +30,17 @@
|
||||
<input
|
||||
bitCheckbox
|
||||
type="checkbox"
|
||||
[checked]="selectedUrls.has(row.applicationName)"
|
||||
(change)="checkboxChange(row.applicationName, $event)"
|
||||
[checked]="selectedUrls().has(row.applicationName)"
|
||||
(change)="checkboxChange()(row.applicationName, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
bitCell
|
||||
class="tw-cursor-pointer"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers(row.applicationName)"
|
||||
(click)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
@@ -42,9 +52,9 @@
|
||||
<td
|
||||
class="tw-cursor-pointer tw-align-middle"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers(row.applicationName)"
|
||||
(click)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
@@ -62,9 +72,9 @@
|
||||
bitCell
|
||||
class="tw-cursor-pointer"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers(row.applicationName)"
|
||||
(click)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
@@ -77,9 +87,9 @@
|
||||
bitCell
|
||||
class="tw-cursor-pointer"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers(row.applicationName)"
|
||||
(click)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
@@ -92,9 +102,9 @@
|
||||
bitCell
|
||||
class="tw-cursor-pointer"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers(row.applicationName)"
|
||||
(click)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
@@ -108,36 +118,15 @@
|
||||
data-testid="total-membership"
|
||||
class="tw-cursor-pointer"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers(row.applicationName)"
|
||||
(click)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
>
|
||||
{{ row.memberCount }}
|
||||
</td>
|
||||
@if (showRowMenuForCriticalApps) {
|
||||
<td
|
||||
bitCell
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
appStopProp
|
||||
>
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
label="{{ 'options' | i18n }}"
|
||||
tabindex="0"
|
||||
></button>
|
||||
<bit-menu #rowMenu>
|
||||
<button type="button" bitMenuItem (click)="unmarkAsCritical(row.applicationName)">
|
||||
{{ "unmarkAsCritical" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
</td>
|
||||
}
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { DebugElement } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ApplicationHealthReportDetailEnriched } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { TableDataSource } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { AppTableRowScrollableM11Component } from "./app-table-row-scrollable-m11.component";
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
const mockTableData: ApplicationHealthReportDetailEnriched[] = [
|
||||
{
|
||||
applicationName: "google.com",
|
||||
passwordCount: 5,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1" as any, "cipher-2" as any],
|
||||
memberCount: 3,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [
|
||||
{
|
||||
userGuid: "user-1",
|
||||
userName: "John Doe",
|
||||
email: "john@google.com",
|
||||
cipherId: "cipher-1",
|
||||
},
|
||||
],
|
||||
atRiskMemberDetails: [
|
||||
{
|
||||
userGuid: "user-2",
|
||||
userName: "Jane Smith",
|
||||
email: "jane@google.com",
|
||||
cipherId: "cipher-2",
|
||||
},
|
||||
],
|
||||
cipherIds: ["cipher-1" as any, "cipher-2" as any],
|
||||
isMarkedAsCritical: true,
|
||||
},
|
||||
{
|
||||
applicationName: "facebook.com",
|
||||
passwordCount: 3,
|
||||
atRiskPasswordCount: 1,
|
||||
atRiskCipherIds: ["cipher-3" as any],
|
||||
memberCount: 2,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [
|
||||
{
|
||||
userGuid: "user-3",
|
||||
userName: "Alice Johnson",
|
||||
email: "alice@facebook.com",
|
||||
cipherId: "cipher-3",
|
||||
},
|
||||
],
|
||||
atRiskMemberDetails: [
|
||||
{
|
||||
userGuid: "user-4",
|
||||
userName: "Bob Wilson",
|
||||
email: "bob@facebook.com",
|
||||
cipherId: "cipher-4",
|
||||
},
|
||||
],
|
||||
cipherIds: ["cipher-3" as any, "cipher-4" as any],
|
||||
isMarkedAsCritical: false,
|
||||
},
|
||||
{
|
||||
applicationName: "twitter.com",
|
||||
passwordCount: 4,
|
||||
atRiskPasswordCount: 0,
|
||||
atRiskCipherIds: [],
|
||||
memberCount: 4,
|
||||
atRiskMemberCount: 0,
|
||||
memberDetails: [],
|
||||
atRiskMemberDetails: [],
|
||||
cipherIds: ["cipher-5" as any, "cipher-6" as any],
|
||||
isMarkedAsCritical: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe("AppTableRowScrollableM11Component", () => {
|
||||
let fixture: ComponentFixture<AppTableRowScrollableM11Component>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockI18nService = mock<I18nService>();
|
||||
mockI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppTableRowScrollableM11Component],
|
||||
providers: [
|
||||
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AppTableRowScrollableM11Component);
|
||||
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
describe("select all checkbox", () => {
|
||||
let selectAllCheckboxEl: DebugElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
selectAllCheckboxEl = fixture.debugElement.query(By.css('[data-testid="selectAll"]'));
|
||||
});
|
||||
|
||||
it("should check all rows in table when checked", () => {
|
||||
// arrange
|
||||
const selectedUrls = new Set<string>();
|
||||
const dataSource = new TableDataSource<ApplicationHealthReportDetailEnriched>();
|
||||
dataSource.data = mockTableData;
|
||||
|
||||
fixture.componentRef.setInput("selectedUrls", selectedUrls);
|
||||
fixture.componentRef.setInput("dataSource", dataSource);
|
||||
fixture.detectChanges();
|
||||
|
||||
// act
|
||||
selectAllCheckboxEl.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
// assert
|
||||
expect(selectedUrls.has("google.com")).toBe(true);
|
||||
expect(selectedUrls.has("facebook.com")).toBe(true);
|
||||
expect(selectedUrls.has("twitter.com")).toBe(true);
|
||||
expect(selectedUrls.size).toBe(3);
|
||||
});
|
||||
|
||||
it("should uncheck all rows in table when unchecked", () => {
|
||||
// arrange
|
||||
const selectedUrls = new Set<string>(["google.com", "facebook.com", "twitter.com"]);
|
||||
const dataSource = new TableDataSource<ApplicationHealthReportDetailEnriched>();
|
||||
dataSource.data = mockTableData;
|
||||
|
||||
fixture.componentRef.setInput("selectedUrls", selectedUrls);
|
||||
fixture.componentRef.setInput("dataSource", dataSource);
|
||||
fixture.detectChanges();
|
||||
|
||||
// act
|
||||
selectAllCheckboxEl.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
// assert
|
||||
expect(selectedUrls.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should become checked when all rows in table are checked", () => {
|
||||
// arrange
|
||||
const selectedUrls = new Set<string>(["google.com", "facebook.com", "twitter.com"]);
|
||||
const dataSource = new TableDataSource<ApplicationHealthReportDetailEnriched>();
|
||||
dataSource.data = mockTableData;
|
||||
|
||||
fixture.componentRef.setInput("selectedUrls", selectedUrls);
|
||||
fixture.componentRef.setInput("dataSource", dataSource);
|
||||
fixture.detectChanges();
|
||||
|
||||
// assert
|
||||
expect(selectAllCheckboxEl.nativeElement.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("should become unchecked when any row in table is unchecked", () => {
|
||||
// arrange
|
||||
const selectedUrls = new Set<string>(["google.com", "facebook.com"]);
|
||||
const dataSource = new TableDataSource<ApplicationHealthReportDetailEnriched>();
|
||||
dataSource.data = mockTableData;
|
||||
|
||||
fixture.componentRef.setInput("selectedUrls", selectedUrls);
|
||||
fixture.componentRef.setInput("dataSource", dataSource);
|
||||
fixture.detectChanges();
|
||||
|
||||
// assert
|
||||
expect(selectAllCheckboxEl.nativeElement.checked).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { MenuModule, TableDataSource, TableModule } from "@bitwarden/components";
|
||||
import { MenuModule, TableDataSource, TableModule, TooltipDirective } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
|
||||
@@ -11,34 +11,52 @@ import { ApplicationTableDataSource } from "./app-table-row-scrollable.component
|
||||
//TODO: Rename this component to AppTableRowScrollableComponent once milestone 11 is fully rolled out
|
||||
//TODO: Move definition of ApplicationTableDataSource to this file from app-table-row-scrollable.component.ts
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "app-table-row-scrollable-m11",
|
||||
imports: [CommonModule, JslibModule, TableModule, SharedModule, PipesModule, MenuModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
TableModule,
|
||||
SharedModule,
|
||||
PipesModule,
|
||||
MenuModule,
|
||||
TooltipDirective,
|
||||
],
|
||||
templateUrl: "./app-table-row-scrollable-m11.component.html",
|
||||
})
|
||||
export class AppTableRowScrollableM11Component {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input()
|
||||
dataSource!: TableDataSource<ApplicationTableDataSource>;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() showRowMenuForCriticalApps: boolean = false;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() selectedUrls: Set<string> = new Set<string>();
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() openApplication: string = "";
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() showAppAtRiskMembers!: (applicationName: string) => void;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() unmarkAsCritical!: (applicationName: string) => void;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() checkboxChange!: (applicationName: string, $event: Event) => void;
|
||||
readonly dataSource = input<TableDataSource<ApplicationTableDataSource>>();
|
||||
readonly selectedUrls = input<Set<string>>();
|
||||
readonly openApplication = input<string>("");
|
||||
readonly showAppAtRiskMembers = input<(applicationName: string) => void>();
|
||||
readonly checkboxChange = input<(applicationName: string, $event: Event) => void>();
|
||||
|
||||
allAppsSelected(): boolean {
|
||||
const tableData = this.dataSource()?.filteredData;
|
||||
const selectedUrls = this.selectedUrls();
|
||||
|
||||
if (!tableData || !selectedUrls) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return tableData.length > 0 && tableData.every((row) => selectedUrls.has(row.applicationName));
|
||||
}
|
||||
|
||||
selectAllChanged(target: HTMLInputElement) {
|
||||
const checked = target.checked;
|
||||
|
||||
const tableData = this.dataSource()?.filteredData;
|
||||
const selectedUrls = this.selectedUrls();
|
||||
|
||||
if (!tableData || !selectedUrls) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
tableData.forEach((row) => selectedUrls.add(row.applicationName));
|
||||
} else {
|
||||
selectedUrls.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,8 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
|
||||
const response = await this.inFlightApiCalls.sync;
|
||||
|
||||
await this.cipherService.clear(response.profile.id);
|
||||
|
||||
await this.syncUserDecryption(response.profile.id, response.userDecryption);
|
||||
await this.syncProfile(response.profile);
|
||||
await this.syncFolders(response.folders, response.profile.id);
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 {
|
||||
@@ -20,7 +19,7 @@ export abstract class SearchService {
|
||||
abstract isSearchable(userId: UserId, query: string | null): Promise<boolean>;
|
||||
abstract indexCiphers(
|
||||
userId: UserId,
|
||||
ciphersToIndex: CipherView[],
|
||||
ciphersToIndex: CipherViewLike[],
|
||||
indexedEntityGuid?: string,
|
||||
): Promise<void>;
|
||||
abstract searchCiphers<C extends CipherViewLike>(
|
||||
|
||||
@@ -205,6 +205,70 @@ describe("CipherAuthorizationService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEditCipher$", () => {
|
||||
it("should return true if isAdminConsoleAction is true and cipher is unassigned", (done) => {
|
||||
const cipher = createMockCipher("org1", []) as CipherView;
|
||||
const organization = createMockOrganization({ canEditUnassignedCiphers: true });
|
||||
mockOrganizationService.organizations$.mockReturnValue(
|
||||
of([organization]) as Observable<Organization[]>,
|
||||
);
|
||||
|
||||
cipherAuthorizationService.canEditCipher$(cipher, true).subscribe((result) => {
|
||||
expect(result).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return true if isAdminConsoleAction is true and user can edit all ciphers in the org", (done) => {
|
||||
const cipher = createMockCipher("org1", ["col1"]) as CipherView;
|
||||
const organization = createMockOrganization({ canEditAllCiphers: true });
|
||||
mockOrganizationService.organizations$.mockReturnValue(
|
||||
of([organization]) as Observable<Organization[]>,
|
||||
);
|
||||
|
||||
cipherAuthorizationService.canEditCipher$(cipher, true).subscribe((result) => {
|
||||
expect(result).toBe(true);
|
||||
expect(mockOrganizationService.organizations$).toHaveBeenCalledWith(mockUserId);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if isAdminConsoleAction is true but user does not have permission to edit unassigned ciphers", (done) => {
|
||||
const cipher = createMockCipher("org1", []) as CipherView;
|
||||
const organization = createMockOrganization({ canEditUnassignedCiphers: false });
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
|
||||
|
||||
cipherAuthorizationService.canEditCipher$(cipher, true).subscribe((result) => {
|
||||
expect(result).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return true if cipher.edit is true and is not an admin action", (done) => {
|
||||
const cipher = createMockCipher("org1", [], true) as CipherView;
|
||||
const organization = createMockOrganization();
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
|
||||
|
||||
cipherAuthorizationService.canEditCipher$(cipher, false).subscribe((result) => {
|
||||
expect(result).toBe(true);
|
||||
expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if cipher.edit is false and is not an admin action", (done) => {
|
||||
const cipher = createMockCipher("org1", [], false) as CipherView;
|
||||
const organization = createMockOrganization();
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
|
||||
|
||||
cipherAuthorizationService.canEditCipher$(cipher, false).subscribe((result) => {
|
||||
expect(result).toBe(false);
|
||||
expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("canCloneCipher$", () => {
|
||||
it("should return true if cipher has no organizationId", async () => {
|
||||
const cipher = createMockCipher(null, []) as CipherView;
|
||||
|
||||
@@ -53,6 +53,19 @@ export abstract class CipherAuthorizationService {
|
||||
cipher: CipherLike,
|
||||
isAdminConsoleAction?: boolean,
|
||||
) => Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Determines if the user can edit the specified cipher.
|
||||
*
|
||||
* @param {CipherLike} cipher - The cipher object to evaluate for edit permissions.
|
||||
* @param {boolean} isAdminConsoleAction - Optional. A flag indicating if the action is being performed from the admin console.
|
||||
*
|
||||
* @returns {Observable<boolean>} - An observable that emits a boolean value indicating if the user can edit the cipher.
|
||||
*/
|
||||
abstract canEditCipher$: (
|
||||
cipher: CipherLike,
|
||||
isAdminConsoleAction?: boolean,
|
||||
) => Observable<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,6 +131,29 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* {@link CipherAuthorizationService.canEditCipher$}
|
||||
*/
|
||||
canEditCipher$(cipher: CipherLike, isAdminConsoleAction?: boolean): Observable<boolean> {
|
||||
return this.organization$(cipher).pipe(
|
||||
map((organization) => {
|
||||
if (isAdminConsoleAction) {
|
||||
// If the user is an admin, they can edit an unassigned cipher
|
||||
if (!cipher.collectionIds || cipher.collectionIds.length === 0) {
|
||||
return organization?.canEditUnassignedCiphers === true;
|
||||
}
|
||||
|
||||
if (organization?.canEditAllCiphers) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return !!cipher.edit;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link CipherAuthorizationService.canCloneCipher$}
|
||||
*/
|
||||
|
||||
@@ -173,13 +173,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
decryptStartTime = performance.now();
|
||||
}),
|
||||
switchMap(async (ciphers) => {
|
||||
const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false);
|
||||
void this.setFailedDecryptedCiphers(failures, userId);
|
||||
// Trigger full decryption and indexing in background
|
||||
void this.getAllDecrypted(userId);
|
||||
return decrypted;
|
||||
return await this.decryptCiphersWithSdk(ciphers, userId, false);
|
||||
}),
|
||||
tap((decrypted) => {
|
||||
tap(([decrypted, failures]) => {
|
||||
void Promise.all([
|
||||
this.setFailedDecryptedCiphers(failures, userId),
|
||||
this.searchService.indexCiphers(userId, decrypted),
|
||||
]);
|
||||
|
||||
this.logService.measure(
|
||||
decryptStartTime,
|
||||
"Vault",
|
||||
@@ -188,10 +189,11 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
[["Items", decrypted.length]],
|
||||
);
|
||||
}),
|
||||
map(([decrypted]) => decrypted),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}, this.clearCipherViewsForUser$);
|
||||
|
||||
/**
|
||||
* Observable that emits an array of decrypted ciphers for the active user.
|
||||
|
||||
@@ -95,6 +95,7 @@ describe("DefaultCipherEncryptionService", () => {
|
||||
vault: jest.fn().mockReturnValue({
|
||||
ciphers: jest.fn().mockReturnValue({
|
||||
encrypt: jest.fn(),
|
||||
encrypt_list: jest.fn(),
|
||||
encrypt_cipher_for_rotation: jest.fn(),
|
||||
set_fido2_credentials: jest.fn(),
|
||||
decrypt: jest.fn(),
|
||||
@@ -280,10 +281,23 @@ describe("DefaultCipherEncryptionService", () => {
|
||||
name: "encrypted-name-3",
|
||||
} as unknown as Cipher;
|
||||
|
||||
mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
|
||||
cipher: sdkCipher,
|
||||
encryptedFor: userId,
|
||||
});
|
||||
mockSdkClient
|
||||
.vault()
|
||||
.ciphers()
|
||||
.encrypt_list.mockReturnValue([
|
||||
{
|
||||
cipher: sdkCipher,
|
||||
encryptedFor: userId,
|
||||
},
|
||||
{
|
||||
cipher: sdkCipher,
|
||||
encryptedFor: userId,
|
||||
},
|
||||
{
|
||||
cipher: sdkCipher,
|
||||
encryptedFor: userId,
|
||||
},
|
||||
]);
|
||||
|
||||
jest
|
||||
.spyOn(Cipher, "fromSdkCipher")
|
||||
@@ -299,7 +313,8 @@ describe("DefaultCipherEncryptionService", () => {
|
||||
expect(results[1].cipher).toEqual(expectedCipher2);
|
||||
expect(results[2].cipher).toEqual(expectedCipher3);
|
||||
|
||||
expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledTimes(3);
|
||||
expect(mockSdkClient.vault().ciphers().encrypt_list).toHaveBeenCalledTimes(1);
|
||||
expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled();
|
||||
|
||||
expect(results[0].encryptedFor).toBe(userId);
|
||||
expect(results[1].encryptedFor).toBe(userId);
|
||||
@@ -311,7 +326,7 @@ describe("DefaultCipherEncryptionService", () => {
|
||||
|
||||
expect(results).toBeDefined();
|
||||
expect(results.length).toBe(0);
|
||||
expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled();
|
||||
expect(mockSdkClient.vault().ciphers().encrypt_list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -65,21 +65,14 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
|
||||
|
||||
using ref = sdk.take();
|
||||
|
||||
const results: EncryptionContext[] = [];
|
||||
|
||||
// TODO: https://bitwarden.atlassian.net/browse/PM-30580
|
||||
// Replace this loop with a native SDK encryptMany method for better performance.
|
||||
for (const model of models) {
|
||||
const sdkCipherView = this.toSdkCipherView(model, ref.value);
|
||||
const encryptionContext = ref.value.vault().ciphers().encrypt(sdkCipherView);
|
||||
|
||||
results.push({
|
||||
return ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.encrypt_list(models.map((model) => this.toSdkCipherView(model, ref.value)))
|
||||
.map((encryptionContext) => ({
|
||||
cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!,
|
||||
encryptedFor: uuidAsString(encryptionContext.encryptedFor) as UserId,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}));
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to encrypt ciphers in batch: ${error}`);
|
||||
|
||||
@@ -21,7 +21,6 @@ import { IndexedEntityId, UserId } from "../../types/guid";
|
||||
import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service";
|
||||
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";
|
||||
|
||||
// Time to wait before performing a search after the user stops typing.
|
||||
@@ -169,7 +168,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
|
||||
async indexCiphers(
|
||||
userId: UserId,
|
||||
ciphers: CipherView[],
|
||||
ciphers: CipherViewLike[],
|
||||
indexedEntityId?: string,
|
||||
): Promise<void> {
|
||||
if (await this.getIsIndexing(userId)) {
|
||||
@@ -182,34 +181,47 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
const builder = new lunr.Builder();
|
||||
builder.pipeline.add(this.normalizeAccentsPipelineFunction);
|
||||
builder.ref("id");
|
||||
builder.field("shortid", { boost: 100, extractor: (c: CipherView) => c.id.substr(0, 8) });
|
||||
builder.field("shortid", {
|
||||
boost: 100,
|
||||
extractor: (c: CipherViewLike) => uuidAsString(c.id).substr(0, 8),
|
||||
});
|
||||
builder.field("name", {
|
||||
boost: 10,
|
||||
});
|
||||
builder.field("subtitle", {
|
||||
boost: 5,
|
||||
extractor: (c: CipherView) => {
|
||||
if (c.subTitle != null && c.type === CipherType.Card) {
|
||||
return c.subTitle.replace(/\*/g, "");
|
||||
extractor: (c: CipherViewLike) => {
|
||||
const subtitle = CipherViewLikeUtils.subtitle(c);
|
||||
if (subtitle != null && CipherViewLikeUtils.getType(c) === CipherType.Card) {
|
||||
return subtitle.replace(/\*/g, "");
|
||||
}
|
||||
return c.subTitle;
|
||||
return subtitle;
|
||||
},
|
||||
});
|
||||
builder.field("notes");
|
||||
builder.field("notes", { extractor: (c: CipherViewLike) => CipherViewLikeUtils.getNotes(c) });
|
||||
builder.field("login.username", {
|
||||
extractor: (c: CipherView) =>
|
||||
c.type === CipherType.Login && c.login != null ? c.login.username : null,
|
||||
extractor: (c: CipherViewLike) => {
|
||||
const login = CipherViewLikeUtils.getLogin(c);
|
||||
return login?.username ?? null;
|
||||
},
|
||||
});
|
||||
builder.field("login.uris", {
|
||||
boost: 2,
|
||||
extractor: (c: CipherViewLike) => this.uriExtractor(c),
|
||||
});
|
||||
builder.field("fields", {
|
||||
extractor: (c: CipherViewLike) => this.fieldExtractor(c, false),
|
||||
});
|
||||
builder.field("fields_joined", {
|
||||
extractor: (c: CipherViewLike) => this.fieldExtractor(c, true),
|
||||
});
|
||||
builder.field("login.uris", { boost: 2, extractor: (c: CipherView) => this.uriExtractor(c) });
|
||||
builder.field("fields", { extractor: (c: CipherView) => this.fieldExtractor(c, false) });
|
||||
builder.field("fields_joined", { extractor: (c: CipherView) => this.fieldExtractor(c, true) });
|
||||
builder.field("attachments", {
|
||||
extractor: (c: CipherView) => this.attachmentExtractor(c, false),
|
||||
extractor: (c: CipherViewLike) => this.attachmentExtractor(c, false),
|
||||
});
|
||||
builder.field("attachments_joined", {
|
||||
extractor: (c: CipherView) => this.attachmentExtractor(c, true),
|
||||
extractor: (c: CipherViewLike) => this.attachmentExtractor(c, true),
|
||||
});
|
||||
builder.field("organizationid", { extractor: (c: CipherView) => c.organizationId });
|
||||
builder.field("organizationid", { extractor: (c: CipherViewLike) => c.organizationId });
|
||||
ciphers = ciphers || [];
|
||||
ciphers.forEach((c) => builder.add(c));
|
||||
const index = builder.build();
|
||||
@@ -400,37 +412,44 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
return await firstValueFrom(this.searchIsIndexing$(userId));
|
||||
}
|
||||
|
||||
private fieldExtractor(c: CipherView, joined: boolean) {
|
||||
if (!c.hasFields) {
|
||||
private fieldExtractor(c: CipherViewLike, joined: boolean) {
|
||||
const fields = CipherViewLikeUtils.getFields(c);
|
||||
if (!fields || fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let fields: string[] = [];
|
||||
c.fields.forEach((f) => {
|
||||
let fieldStrings: string[] = [];
|
||||
fields.forEach((f) => {
|
||||
if (f.name != null) {
|
||||
fields.push(f.name);
|
||||
fieldStrings.push(f.name);
|
||||
}
|
||||
if (f.type === FieldType.Text && f.value != null) {
|
||||
fields.push(f.value);
|
||||
// For CipherListView, value is only populated for Text fields
|
||||
// For CipherView, we check the type explicitly
|
||||
if (f.value != null) {
|
||||
const fieldType = (f as { type?: FieldType }).type;
|
||||
if (fieldType === undefined || fieldType === FieldType.Text) {
|
||||
fieldStrings.push(f.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
fields = fields.filter((f) => f.trim() !== "");
|
||||
if (fields.length === 0) {
|
||||
fieldStrings = fieldStrings.filter((f) => f.trim() !== "");
|
||||
if (fieldStrings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return joined ? fields.join(" ") : fields;
|
||||
return joined ? fieldStrings.join(" ") : fieldStrings;
|
||||
}
|
||||
|
||||
private attachmentExtractor(c: CipherView, joined: boolean) {
|
||||
if (!c.hasAttachments) {
|
||||
private attachmentExtractor(c: CipherViewLike, joined: boolean) {
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(c);
|
||||
if (!attachmentNames || attachmentNames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let attachments: string[] = [];
|
||||
c.attachments.forEach((a) => {
|
||||
if (a != null && a.fileName != null) {
|
||||
if (joined && a.fileName.indexOf(".") > -1) {
|
||||
attachments.push(a.fileName.substr(0, a.fileName.lastIndexOf(".")));
|
||||
attachmentNames.forEach((fileName) => {
|
||||
if (fileName != null) {
|
||||
if (joined && fileName.indexOf(".") > -1) {
|
||||
attachments.push(fileName.substring(0, fileName.lastIndexOf(".")));
|
||||
} else {
|
||||
attachments.push(a.fileName);
|
||||
attachments.push(fileName);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -441,43 +460,39 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
return joined ? attachments.join(" ") : attachments;
|
||||
}
|
||||
|
||||
private uriExtractor(c: CipherView) {
|
||||
if (c.type !== CipherType.Login || c.login == null || !c.login.hasUris) {
|
||||
private uriExtractor(c: CipherViewLike) {
|
||||
if (CipherViewLikeUtils.getType(c) !== CipherType.Login) {
|
||||
return null;
|
||||
}
|
||||
const login = CipherViewLikeUtils.getLogin(c);
|
||||
if (!login?.uris?.length) {
|
||||
return null;
|
||||
}
|
||||
const uris: string[] = [];
|
||||
c.login.uris.forEach((u) => {
|
||||
login.uris.forEach((u) => {
|
||||
if (u.uri == null || u.uri === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Match ports
|
||||
// Extract port from URI
|
||||
const portMatch = u.uri.match(/:(\d+)(?:[/?#]|$)/);
|
||||
const port = portMatch?.[1];
|
||||
|
||||
let uri = u.uri;
|
||||
|
||||
if (u.hostname !== null) {
|
||||
uris.push(u.hostname);
|
||||
const hostname = CipherViewLikeUtils.getUriHostname(u);
|
||||
if (hostname !== undefined) {
|
||||
uris.push(hostname);
|
||||
if (port) {
|
||||
uris.push(`${u.hostname}:${port}`);
|
||||
uris.push(port);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const slash = uri.indexOf("/");
|
||||
const hostPart = slash > -1 ? uri.substring(0, slash) : uri;
|
||||
uris.push(hostPart);
|
||||
if (port) {
|
||||
uris.push(`${hostPart}`);
|
||||
uris.push(`${hostname}:${port}`);
|
||||
uris.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
// Add processed URI (strip protocol and query params for non-regex matches)
|
||||
let uri = u.uri;
|
||||
if (u.match !== UriMatchStrategy.RegularExpression) {
|
||||
const protocolIndex = uri.indexOf("://");
|
||||
if (protocolIndex > -1) {
|
||||
uri = uri.substr(protocolIndex + 3);
|
||||
uri = uri.substring(protocolIndex + 3);
|
||||
}
|
||||
const queryIndex = uri.search(/\?|&|#/);
|
||||
if (queryIndex > -1) {
|
||||
@@ -486,6 +501,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
}
|
||||
uris.push(uri);
|
||||
});
|
||||
|
||||
return uris.length > 0 ? uris : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -651,4 +651,198 @@ describe("CipherViewLikeUtils", () => {
|
||||
expect(CipherViewLikeUtils.decryptionFailure(cipherListView)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNotes", () => {
|
||||
describe("CipherView", () => {
|
||||
it("returns notes when present", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.notes = "This is a test note";
|
||||
|
||||
expect(CipherViewLikeUtils.getNotes(cipherView)).toBe("This is a test note");
|
||||
});
|
||||
|
||||
it("returns undefined when notes are not present", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.notes = undefined;
|
||||
|
||||
expect(CipherViewLikeUtils.getNotes(cipherView)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CipherListView", () => {
|
||||
it("returns notes when present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
notes: "List view notes",
|
||||
} as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getNotes(cipherListView)).toBe("List view notes");
|
||||
});
|
||||
|
||||
it("returns undefined when notes are not present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
} as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getNotes(cipherListView)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFields", () => {
|
||||
describe("CipherView", () => {
|
||||
it("returns fields when present", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.fields = [
|
||||
{ name: "Field1", value: "Value1" } as any,
|
||||
{ name: "Field2", value: "Value2" } as any,
|
||||
];
|
||||
|
||||
const fields = CipherViewLikeUtils.getFields(cipherView);
|
||||
|
||||
expect(fields).toHaveLength(2);
|
||||
expect(fields?.[0].name).toBe("Field1");
|
||||
expect(fields?.[0].value).toBe("Value1");
|
||||
expect(fields?.[1].name).toBe("Field2");
|
||||
expect(fields?.[1].value).toBe("Value2");
|
||||
});
|
||||
|
||||
it("returns empty array when fields array is empty", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.fields = [];
|
||||
|
||||
expect(CipherViewLikeUtils.getFields(cipherView)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CipherListView", () => {
|
||||
it("returns fields when present", () => {
|
||||
const cipherListView = {
|
||||
type: { login: {} },
|
||||
fields: [
|
||||
{ name: "Username", value: "user@example.com" },
|
||||
{ name: "API Key", value: "abc123" },
|
||||
],
|
||||
} as CipherListView;
|
||||
|
||||
const fields = CipherViewLikeUtils.getFields(cipherListView);
|
||||
|
||||
expect(fields).toHaveLength(2);
|
||||
expect(fields?.[0].name).toBe("Username");
|
||||
expect(fields?.[0].value).toBe("user@example.com");
|
||||
expect(fields?.[1].name).toBe("API Key");
|
||||
expect(fields?.[1].value).toBe("abc123");
|
||||
});
|
||||
|
||||
it("returns empty array when fields array is empty", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
fields: [],
|
||||
} as unknown as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getFields(cipherListView)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns undefined when fields are not present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
} as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getFields(cipherListView)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAttachmentNames", () => {
|
||||
describe("CipherView", () => {
|
||||
it("returns attachment filenames when present", () => {
|
||||
const cipherView = createCipherView();
|
||||
const attachment1 = new AttachmentView();
|
||||
attachment1.id = "1";
|
||||
attachment1.fileName = "document.pdf";
|
||||
const attachment2 = new AttachmentView();
|
||||
attachment2.id = "2";
|
||||
attachment2.fileName = "image.png";
|
||||
const attachment3 = new AttachmentView();
|
||||
attachment3.id = "3";
|
||||
attachment3.fileName = "spreadsheet.xlsx";
|
||||
cipherView.attachments = [attachment1, attachment2, attachment3];
|
||||
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
|
||||
|
||||
expect(attachmentNames).toEqual(["document.pdf", "image.png", "spreadsheet.xlsx"]);
|
||||
});
|
||||
|
||||
it("filters out null and undefined filenames", () => {
|
||||
const cipherView = createCipherView();
|
||||
const attachment1 = new AttachmentView();
|
||||
attachment1.id = "1";
|
||||
attachment1.fileName = "valid.pdf";
|
||||
const attachment2 = new AttachmentView();
|
||||
attachment2.id = "2";
|
||||
attachment2.fileName = null as any;
|
||||
const attachment3 = new AttachmentView();
|
||||
attachment3.id = "3";
|
||||
attachment3.fileName = undefined;
|
||||
const attachment4 = new AttachmentView();
|
||||
attachment4.id = "4";
|
||||
attachment4.fileName = "another.txt";
|
||||
cipherView.attachments = [attachment1, attachment2, attachment3, attachment4];
|
||||
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
|
||||
|
||||
expect(attachmentNames).toEqual(["valid.pdf", "another.txt"]);
|
||||
});
|
||||
|
||||
it("returns empty array when attachments have no filenames", () => {
|
||||
const cipherView = createCipherView();
|
||||
const attachment1 = new AttachmentView();
|
||||
attachment1.id = "1";
|
||||
const attachment2 = new AttachmentView();
|
||||
attachment2.id = "2";
|
||||
cipherView.attachments = [attachment1, attachment2];
|
||||
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
|
||||
|
||||
expect(attachmentNames).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty attachments array", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.attachments = [];
|
||||
|
||||
expect(CipherViewLikeUtils.getAttachmentNames(cipherView)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CipherListView", () => {
|
||||
it("returns attachment names when present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
attachmentNames: ["report.pdf", "photo.jpg", "data.csv"],
|
||||
} as CipherListView;
|
||||
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherListView);
|
||||
|
||||
expect(attachmentNames).toEqual(["report.pdf", "photo.jpg", "data.csv"]);
|
||||
});
|
||||
|
||||
it("returns empty array when attachmentNames is empty", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
attachmentNames: [],
|
||||
} as unknown as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getAttachmentNames(cipherListView)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns undefined when attachmentNames is not present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
} as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getAttachmentNames(cipherListView)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
LoginUriView as LoginListUriView,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { CipherType } from "../enums";
|
||||
import { Cipher } from "../models/domain/cipher";
|
||||
import { CardView } from "../models/view/card.view";
|
||||
@@ -290,6 +291,71 @@ export class CipherViewLikeUtils {
|
||||
static decryptionFailure = (cipher: CipherViewLike): boolean => {
|
||||
return "decryptionFailure" in cipher ? cipher.decryptionFailure : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the notes from the cipher.
|
||||
*
|
||||
* @param cipher - The cipher to extract notes from (either `CipherView` or `CipherListView`)
|
||||
* @returns The notes string if present, or `undefined` if not set
|
||||
*/
|
||||
static getNotes = (cipher: CipherViewLike): string | undefined => {
|
||||
return cipher.notes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the fields from the cipher.
|
||||
*
|
||||
* @param cipher - The cipher to extract fields from (either `CipherView` or `CipherListView`)
|
||||
* @returns Array of field objects with `name` and `value` properties, `undefined` if not set
|
||||
*/
|
||||
static getFields = (
|
||||
cipher: CipherViewLike,
|
||||
): { name?: string | null; value?: string | undefined }[] | undefined => {
|
||||
if (this.isCipherListView(cipher)) {
|
||||
return cipher.fields;
|
||||
}
|
||||
return cipher.fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns attachment filenames from the cipher.
|
||||
*
|
||||
* @param cipher - The cipher to extract attachment names from (either `CipherView` or `CipherListView`)
|
||||
* @returns Array of attachment filenames, `undefined` if attachments are not present
|
||||
*/
|
||||
static getAttachmentNames = (cipher: CipherViewLike): string[] | undefined => {
|
||||
if (this.isCipherListView(cipher)) {
|
||||
return cipher.attachmentNames;
|
||||
}
|
||||
|
||||
return cipher.attachments
|
||||
?.map((a) => a.fileName)
|
||||
.filter((name): name is string => name != null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts hostname from a login URI.
|
||||
*
|
||||
* @param uri - The URI object (either `LoginUriView` class or `LoginListUriView`)
|
||||
* @returns The hostname if available, `undefined` otherwise
|
||||
*
|
||||
* @remarks
|
||||
* - For `LoginUriView` (CipherView): Uses the built-in `hostname` getter
|
||||
* - For `LoginListUriView` (CipherListView): Computes hostname using `Utils.getHostname()`
|
||||
* - Returns `undefined` for RegularExpression match types or when hostname cannot be extracted
|
||||
*/
|
||||
static getUriHostname = (uri: LoginListUriView | LoginUriView): string | undefined => {
|
||||
if ("hostname" in uri && typeof uri.hostname !== "undefined") {
|
||||
return uri.hostname ?? undefined;
|
||||
}
|
||||
|
||||
if (uri.match !== UriMatchStrategy.RegularExpression && uri.uri) {
|
||||
const hostname = Utils.getHostname(uri.uri);
|
||||
return hostname === "" ? undefined : hostname;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,8 +28,8 @@ export const TOOLTIP_DELAY_MS = 800;
|
||||
host: {
|
||||
"(mouseenter)": "showTooltip()",
|
||||
"(mouseleave)": "hideTooltip()",
|
||||
"(focus)": "showTooltip()",
|
||||
"(blur)": "hideTooltip()",
|
||||
"(focusin)": "onFocusIn($event)",
|
||||
"(focusout)": "onFocusOut()",
|
||||
"[attr.aria-describedby]": "resolvedDescribedByIds()",
|
||||
},
|
||||
})
|
||||
@@ -125,6 +125,20 @@ export class TooltipDirective implements OnInit, OnDestroy {
|
||||
this.destroyTooltip();
|
||||
};
|
||||
|
||||
/**
|
||||
* Show tooltip on focus-visible (keyboard navigation) but not on regular focus (mouse click).
|
||||
*/
|
||||
protected onFocusIn(event: FocusEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.matches(":focus-visible")) {
|
||||
this.showTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
protected onFocusOut() {
|
||||
this.hideTooltip();
|
||||
}
|
||||
|
||||
protected readonly resolvedDescribedByIds = computed(() => {
|
||||
if (this.addTooltipToDescribedby()) {
|
||||
if (this.currentDescribedByIds) {
|
||||
|
||||
@@ -103,13 +103,22 @@ describe("TooltipDirective (visibility only)", () => {
|
||||
expect(isVisible()).toBe(true);
|
||||
}));
|
||||
|
||||
it("sets isVisible to true on focus", fakeAsync(() => {
|
||||
it("sets isVisible to true on focus-visible", fakeAsync(() => {
|
||||
const button: HTMLButtonElement = fixture.debugElement.query(By.css("button")).nativeElement;
|
||||
const directive = getDirective();
|
||||
|
||||
const isVisible = (directive as unknown as { isVisible: () => boolean }).isVisible;
|
||||
|
||||
button.dispatchEvent(new Event("focus"));
|
||||
// Mock matches to return true for :focus-visible (simulates keyboard navigation)
|
||||
const originalMatches = button.matches.bind(button);
|
||||
button.matches = jest.fn((selector: string) => {
|
||||
if (selector === ":focus-visible") {
|
||||
return true;
|
||||
}
|
||||
return originalMatches(selector);
|
||||
});
|
||||
|
||||
button.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
||||
tick(TOOLTIP_DELAY_MS);
|
||||
expect(isVisible()).toBe(true);
|
||||
}));
|
||||
|
||||
@@ -26,16 +26,22 @@
|
||||
></i>
|
||||
</div>
|
||||
{{ send.name }}
|
||||
<ng-container *ngIf="send.maxAccessCountReached">
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
appStopProp
|
||||
title="{{ 'maxAccessCountReached' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "maxAccessCountReached" | i18n }}</span>
|
||||
</ng-container>
|
||||
|
||||
<div slot="default-trailing" class="tw-flex tw-gap-2 tw-relative tw-z-10">
|
||||
@if (send.authType !== authType.None) {
|
||||
@let titleKey =
|
||||
send.authType === authType.Email ? "emailProtected" : "passwordProtected";
|
||||
<i class="bwi bwi-lock" appA11yTitle="{{ titleKey | i18n }}" aria-hidden="true"></i>
|
||||
<span class="tw-sr-only">{{ titleKey | i18n }}</span>
|
||||
}
|
||||
@if (send.maxAccessCountReached) {
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
appA11yTitle="{{ 'maxAccessCountReached' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "maxAccessCountReached" | i18n }}</span>
|
||||
}
|
||||
</div>
|
||||
<span slot="secondary">
|
||||
{{ "deletionDate" | i18n }}: {{ send.deletionDate | date: "mediumDate" }}
|
||||
</span>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import {
|
||||
BadgeModule,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
})
|
||||
export class SendListItemsContainerComponent {
|
||||
sendType = SendType;
|
||||
authType = AuthType;
|
||||
/**
|
||||
* The list of sends to display.
|
||||
*/
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -23,8 +23,8 @@
|
||||
"@angular/platform-browser": "20.3.16",
|
||||
"@angular/platform-browser-dynamic": "20.3.16",
|
||||
"@angular/router": "20.3.16",
|
||||
"@bitwarden/commercial-sdk-internal": "0.2.0-main.522",
|
||||
"@bitwarden/sdk-internal": "0.2.0-main.522",
|
||||
"@bitwarden/commercial-sdk-internal": "0.2.0-main.527",
|
||||
"@bitwarden/sdk-internal": "0.2.0-main.527",
|
||||
"@electron/fuses": "1.8.0",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@koa/multer": "4.0.0",
|
||||
@@ -4981,9 +4981,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@bitwarden/commercial-sdk-internal": {
|
||||
"version": "0.2.0-main.522",
|
||||
"resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.522.tgz",
|
||||
"integrity": "sha512-2wAbg30cGlDhSj14LaK2/ISuT91XPVeNgL/PU+eoxLhAehGKjAXdvZN3PSwFaAuaMbEFzlESvqC1pzzO4p/1zw==",
|
||||
"version": "0.2.0-main.527",
|
||||
"resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.527.tgz",
|
||||
"integrity": "sha512-4C4lwOgA2v184G2axUR5Jdb4UMXMhF52a/3c0lAZYbD/8Nid6jziE89nCa9hdfdazuPgWXhVFa3gPrhLZ4uTUQ==",
|
||||
"license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT",
|
||||
"dependencies": {
|
||||
"type-fest": "^4.41.0"
|
||||
@@ -5086,9 +5086,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@bitwarden/sdk-internal": {
|
||||
"version": "0.2.0-main.522",
|
||||
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.522.tgz",
|
||||
"integrity": "sha512-E+YqqX/FvGF0vGx6sNJfYaMj88C+rVo51fQPMSHoOePdryFcKQSJX706Glv86OMLMXE7Ln5Lua8LJRftlF/EFQ==",
|
||||
"version": "0.2.0-main.527",
|
||||
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.527.tgz",
|
||||
"integrity": "sha512-dxPh4XjEGFDBASRBEd/JwUdoMAz10W/0QGygYkPwhKKGzJncfDEAgQ/KrT9wc36ycrDrOOspff7xs/vmmzI0+A==",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"type-fest": "^4.41.0"
|
||||
|
||||
@@ -161,8 +161,8 @@
|
||||
"@angular/platform-browser": "20.3.16",
|
||||
"@angular/platform-browser-dynamic": "20.3.16",
|
||||
"@angular/router": "20.3.16",
|
||||
"@bitwarden/commercial-sdk-internal": "0.2.0-main.522",
|
||||
"@bitwarden/sdk-internal": "0.2.0-main.522",
|
||||
"@bitwarden/commercial-sdk-internal": "0.2.0-main.527",
|
||||
"@bitwarden/sdk-internal": "0.2.0-main.527",
|
||||
"@electron/fuses": "1.8.0",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@koa/multer": "4.0.0",
|
||||
|
||||
Reference in New Issue
Block a user