diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2582b96961d..39e5b3f6003 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 972fd60cc2e..c6d9d325e00 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -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." diff --git a/apps/cli/src/commands/restore.command.ts b/apps/cli/src/commands/restore.command.ts index d8cefdfce5d..8c0fc1fbbe1 100644 --- a/apps/cli/src/commands/restore.command.ts +++ b/apps/cli/src/commands/restore.command.ts @@ -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); diff --git a/apps/desktop/src/app/tools/send/send.component.html b/apps/desktop/src/app/tools/send/send.component.html index 2e1acc74475..710a8f08703 100644 --- a/apps/desktop/src/app/tools/send/send.component.html +++ b/apps/desktop/src/app/tools/send/send.component.html @@ -73,11 +73,14 @@ class="flex-list-item" > - {{ s.name }} + {{ s.name }} {{ "disabled" | i18n }} - + @if (s.authType !== authType.None) { + @let titleKey = + s.authType === authType.Email ? "emailProtected" : "passwordProtected"; - {{ "password" | i18n }} - + {{ titleKey | i18n }} + } { - 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",