{
- 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 {
- return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
+ async getAllCiphers(): Promise {
+ if (this.organization) {
+ return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
+ }
+ return [];
}
canManageCipher(c: CipherView): boolean {
diff --git a/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts
index 6b93b289df9..4104e16b3b5 100644
--- a/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts
+++ b/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts
@@ -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 {
if (this.organization) {
- return await this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
+ return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
}
return [];
}
diff --git a/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts
index 0ae9ecad0cb..683b195b271 100644
--- a/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts
+++ b/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts
@@ -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 {
- return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
+ async getAllCiphers(): Promise {
+ if (this.organization) {
+ return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
+ }
+ return [];
}
canManageCipher(c: CipherView): boolean {
diff --git a/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts
index 0b7cd3bfe7c..893a5058bd2 100644
--- a/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts
+++ b/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts
@@ -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 {
- return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
+ async getAllCiphers(): Promise {
+ if (this.organization) {
+ return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
+ }
+ return [];
}
protected canManageCipher(c: CipherView): boolean {
diff --git a/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts
index 411295ceb2a..aadd015e29d 100644
--- a/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts
+++ b/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts
@@ -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 {
- return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
+ async getAllCiphers(): Promise {
+ if (this.organization) {
+ return this.cipherService.getAllFromApiForOrganization(this.organization.id, true);
+ }
+ return [];
}
canManageCipher(c: CipherView): boolean {
diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts
index df73aacfdde..0fe63ed43bd 100644
--- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts
+++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts
@@ -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,
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 89b3b3ac5c6..97bb46029a7 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -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."
},
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts
index c1a00731100..df47adb4635 100644
--- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts
@@ -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",
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts
index 796c0acf220..5b9cea436a0 100644
--- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts
@@ -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();
});
}
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html
index 81304855c8c..a3d29c521c5 100644
--- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html
@@ -33,11 +33,19 @@
{{ "markAppAsCritical" | i18n }}
+
+
;
+};
+
+describe("ApplicationsComponent", () => {
+ let component: ApplicationsComponent;
+ let fixture: ComponentFixture;
+ let mockI18nService: MockProxy;
+ let mockFileDownloadService: MockProxy;
+ let mockLogService: MockProxy;
+ let mockToastService: MockProxy;
+ let mockDataService: MockProxy;
+
+ const reportStatus$ = new BehaviorSubject(ReportStatus.Complete);
+ const enrichedReportData$ = new BehaviorSubject(null);
+ const criticalReportResults$ = new BehaviorSubject(null);
+ const drawerDetails$ = new BehaviorSubject({
+ open: false,
+ invokerId: "",
+ activeDrawerType: DrawerType.None,
+ atRiskMemberDetails: [],
+ appAtRiskMembers: null,
+ atRiskAppDetails: null,
+ });
+
+ beforeEach(async () => {
+ mockI18nService = mock();
+ mockFileDownloadService = mock();
+ mockLogService = mock();
+ mockToastService = mock();
+ mockDataService = mock();
+
+ 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");
+ });
+ });
+});
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts
index 8cd0c2640f5..b5fae36bb2e 100644
--- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts
@@ -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();
+ 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);
+ }
+ };
}
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts
index b61190df660..3033bf139c3 100644
--- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts
@@ -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();
@@ -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"),
+ });
});
}
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html
index 4f231efc04b..67cee2a4639 100644
--- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html
@@ -1,7 +1,17 @@
-
+
- |
+
+
+ |
|
{{ "application" | i18n }} |
@@ -20,17 +30,17 @@
|
{{ row.memberCount }}
|
- @if (showRowMenuForCriticalApps) {
-
-
-
-
-
- |
- }
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.spec.ts
new file mode 100644
index 00000000000..42dcf4cfe28
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.spec.ts
@@ -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;
+
+ beforeEach(async () => {
+ const mockI18nService = mock();
+ mockI18nService.t.mockImplementation((key: string) => key);
+
+ await TestBed.configureTestingModule({
+ imports: [AppTableRowScrollableM11Component],
+ providers: [
+ { provide: I18nPipe, useValue: mock() },
+ { 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();
+ const dataSource = new TableDataSource();
+ 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(["google.com", "facebook.com", "twitter.com"]);
+ const dataSource = new TableDataSource();
+ 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(["google.com", "facebook.com", "twitter.com"]);
+ const dataSource = new TableDataSource();
+ 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(["google.com", "facebook.com"]);
+ const dataSource = new TableDataSource();
+ dataSource.data = mockTableData;
+
+ fixture.componentRef.setInput("selectedUrls", selectedUrls);
+ fixture.componentRef.setInput("dataSource", dataSource);
+ fixture.detectChanges();
+
+ // assert
+ expect(selectAllCheckboxEl.nativeElement.checked).toBe(false);
+ });
+ });
+});
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.ts
index ef870bd5b38..a23d1855ba5 100644
--- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.ts
@@ -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;
- // 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 = new Set();
- // 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>();
+ readonly selectedUrls = input>();
+ readonly openApplication = input("");
+ 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();
+ }
+ }
}
diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts
index 52de14bbc67..9df58f83a8c 100644
--- a/libs/common/src/platform/sync/default-sync.service.ts
+++ b/libs/common/src/platform/sync/default-sync.service.ts
@@ -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);
diff --git a/libs/common/src/vault/abstractions/search.service.ts b/libs/common/src/vault/abstractions/search.service.ts
index 29575ec3af9..b4dfc015efe 100644
--- a/libs/common/src/vault/abstractions/search.service.ts
+++ b/libs/common/src/vault/abstractions/search.service.ts
@@ -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;
abstract indexCiphers(
userId: UserId,
- ciphersToIndex: CipherView[],
+ ciphersToIndex: CipherViewLike[],
indexedEntityGuid?: string,
): Promise;
abstract searchCiphers(
diff --git a/libs/common/src/vault/services/cipher-authorization.service.spec.ts b/libs/common/src/vault/services/cipher-authorization.service.spec.ts
index f1cc8743492..0490fba3d90 100644
--- a/libs/common/src/vault/services/cipher-authorization.service.spec.ts
+++ b/libs/common/src/vault/services/cipher-authorization.service.spec.ts
@@ -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,
+ );
+
+ 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,
+ );
+
+ 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;
diff --git a/libs/common/src/vault/services/cipher-authorization.service.ts b/libs/common/src/vault/services/cipher-authorization.service.ts
index 7f7e2c3f531..eb89819a05e 100644
--- a/libs/common/src/vault/services/cipher-authorization.service.ts
+++ b/libs/common/src/vault/services/cipher-authorization.service.ts
@@ -53,6 +53,19 @@ export abstract class CipherAuthorizationService {
cipher: CipherLike,
isAdminConsoleAction?: boolean,
) => Observable;
+
+ /**
+ * 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} - An observable that emits a boolean value indicating if the user can edit the cipher.
+ */
+ abstract canEditCipher$: (
+ cipher: CipherLike,
+ isAdminConsoleAction?: boolean,
+ ) => Observable;
}
/**
@@ -118,6 +131,29 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
);
}
+ /**
+ *
+ * {@link CipherAuthorizationService.canEditCipher$}
+ */
+ canEditCipher$(cipher: CipherLike, isAdminConsoleAction?: boolean): Observable {
+ 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$}
*/
diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts
index 6373a511724..696ef49065c 100644
--- a/libs/common/src/vault/services/cipher.service.ts
+++ b/libs/common/src/vault/services/cipher.service.ts
@@ -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.
diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts
index a0ca4833b92..98b554b5762 100644
--- a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts
+++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts
@@ -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();
});
});
diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.ts b/libs/common/src/vault/services/default-cipher-encryption.service.ts
index 588265846e0..45542091618 100644
--- a/libs/common/src/vault/services/default-cipher-encryption.service.ts
+++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts
@@ -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}`);
diff --git a/libs/common/src/vault/services/search.service.ts b/libs/common/src/vault/services/search.service.ts
index feb6a7494b5..e14a66aad6f 100644
--- a/libs/common/src/vault/services/search.service.ts
+++ b/libs/common/src/vault/services/search.service.ts
@@ -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 {
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;
}
diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts
index 56b94fcf3ce..2a7bfac2970 100644
--- a/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts
+++ b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts
@@ -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();
+ });
+ });
+ });
});
diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts
index 04adb8d4832..5359bfb958f 100644
--- a/libs/common/src/vault/utils/cipher-view-like-utils.ts
+++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts
@@ -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;
+ };
}
/**
diff --git a/libs/components/src/tooltip/tooltip.directive.ts b/libs/components/src/tooltip/tooltip.directive.ts
index 419b503c911..a50a4d07e26 100644
--- a/libs/components/src/tooltip/tooltip.directive.ts
+++ b/libs/components/src/tooltip/tooltip.directive.ts
@@ -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) {
diff --git a/libs/components/src/tooltip/tooltip.spec.ts b/libs/components/src/tooltip/tooltip.spec.ts
index b3ec710a294..0d73db2d015 100644
--- a/libs/components/src/tooltip/tooltip.spec.ts
+++ b/libs/components/src/tooltip/tooltip.spec.ts
@@ -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);
}));
diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html
index 2ece050e8c3..c4367d3ac57 100644
--- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html
+++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html
@@ -26,16 +26,22 @@
>
{{ send.name }}
-
-
- {{ "maxAccessCountReached" | i18n }}
-
-
+
+ @if (send.authType !== authType.None) {
+ @let titleKey =
+ send.authType === authType.Email ? "emailProtected" : "passwordProtected";
+
+ {{ titleKey | i18n }}
+ }
+ @if (send.maxAccessCountReached) {
+
+ {{ "maxAccessCountReached" | i18n }}
+ }
+
{{ "deletionDate" | i18n }}: {{ send.deletionDate | date: "mediumDate" }}
diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts
index 63f4b97105a..2f543fb5879 100644
--- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts
+++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts
@@ -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.
*/
diff --git a/package-lock.json b/package-lock.json
index 46f0bcf1d42..3bee72f6a73 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
diff --git a/package.json b/package.json
index 751c67afcd1..e09aba142fd 100644
--- a/package.json
+++ b/package.json
@@ -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",